mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e
This commit is contained in:
commit
2ecb4e62bb
239 changed files with 18356 additions and 2494 deletions
96
tests/agent/test_anthropic_output_field_leak.py
Normal file
96
tests/agent/test_anthropic_output_field_leak.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Regression: output-only SDK fields must not leak into Anthropic request input.
|
||||
|
||||
Reproduces HTTP 400 `messages.N.content.M.text.parsed_output: Extra inputs are
|
||||
not permitted`. Anthropic SDK response blocks carry output-only attributes
|
||||
(text blocks: `parsed_output`, `citations=None`; tool_use blocks: `caller`)
|
||||
that the Messages *input* schema forbids. normalize_response captured blocks
|
||||
verbatim via _to_plain_data and replayed them as input → 400.
|
||||
|
||||
Fix: whitelist input-permitted fields per block type at three points —
|
||||
normalize_response capture, _sanitize_replay_block (ordered-blocks replay), and
|
||||
_convert_content_part_to_anthropic (content-list replay).
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.expanduser("~/.hermes/hermes-agent"))
|
||||
|
||||
import pytest
|
||||
from agent.anthropic_adapter import (
|
||||
_sanitize_replay_block,
|
||||
_convert_content_part_to_anthropic,
|
||||
_convert_assistant_message,
|
||||
)
|
||||
|
||||
FORBIDDEN = {"parsed_output", "caller"}
|
||||
|
||||
|
||||
def _assert_clean(block):
|
||||
"""No forbidden output-only key, and no null citations, anywhere."""
|
||||
assert isinstance(block, dict)
|
||||
for k in FORBIDDEN:
|
||||
assert k not in block, f"forbidden field {k!r} survived: {block}"
|
||||
if "citations" in block:
|
||||
assert isinstance(block["citations"], list) and block["citations"], \
|
||||
"citations must be a non-empty list if present (None/[] is input-invalid)"
|
||||
|
||||
|
||||
class TestSanitizeReplayBlock:
|
||||
def test_text_block_strips_parsed_output_and_null_citations(self):
|
||||
poisoned = {"type": "text", "text": "hi", "parsed_output": None, "citations": None}
|
||||
out = _sanitize_replay_block(poisoned)
|
||||
_assert_clean(out)
|
||||
assert out == {"type": "text", "text": "hi"}
|
||||
|
||||
def test_tool_use_strips_caller(self):
|
||||
poisoned = {"type": "tool_use", "id": "toolu_1", "name": "read_file",
|
||||
"input": {"path": "a"}, "caller": {"type": "agent"}}
|
||||
out = _sanitize_replay_block(poisoned)
|
||||
_assert_clean(out)
|
||||
assert out["name"] == "read_file" and out["input"] == {"path": "a"}
|
||||
|
||||
def test_thinking_preserves_signature(self):
|
||||
b = {"type": "thinking", "thinking": "x", "signature": "sig-AAA"}
|
||||
out = _sanitize_replay_block(b)
|
||||
assert out == {"type": "thinking", "thinking": "x", "signature": "sig-AAA"}
|
||||
|
||||
def test_text_keeps_real_citations(self):
|
||||
real = [{"type": "char_location", "cited_text": "q"}]
|
||||
out = _sanitize_replay_block({"type": "text", "text": "t", "citations": real})
|
||||
assert out["citations"] == real
|
||||
|
||||
def test_unknown_type_dropped(self):
|
||||
assert _sanitize_replay_block({"type": "server_tool_use", "foo": 1}) is None
|
||||
|
||||
|
||||
class TestContentPartConversion:
|
||||
def test_stored_text_block_with_parsed_output_cleaned(self):
|
||||
# The exact content.N.text.parsed_output failure shape.
|
||||
part = {"type": "text", "text": "hello", "parsed_output": None, "citations": None}
|
||||
out = _convert_content_part_to_anthropic(part)
|
||||
_assert_clean(out)
|
||||
|
||||
|
||||
class TestAssistantReplay:
|
||||
def test_interleaved_blocks_replayed_clean_and_ordered(self):
|
||||
m = {
|
||||
"role": "assistant",
|
||||
"anthropic_content_blocks": [
|
||||
{"type": "thinking", "thinking": "plan", "signature": "s1"},
|
||||
{"type": "text", "text": "doing it", "parsed_output": None, "citations": None},
|
||||
{"type": "tool_use", "id": "toolu_1", "name": "read_file",
|
||||
"input": {"path": "a"}, "caller": {"type": "agent"}},
|
||||
],
|
||||
}
|
||||
out = _convert_assistant_message(m)
|
||||
blocks = out["content"]
|
||||
# order preserved
|
||||
assert [b["type"] for b in blocks] == ["thinking", "text", "tool_use"]
|
||||
# every block clean
|
||||
for b in blocks:
|
||||
_assert_clean(b)
|
||||
# signature + tool fields intact
|
||||
assert blocks[0]["signature"] == "s1"
|
||||
assert blocks[2]["name"] == "read_file"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
314
tests/agent/test_anthropic_thinking_block_order.py
Normal file
314
tests/agent/test_anthropic_thinking_block_order.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
"""Regression test for the Anthropic interleaved thinking-block 400.
|
||||
|
||||
Reproduces: HTTP 400 ``messages.N.content.M: thinking or redacted_thinking
|
||||
blocks in the latest assistant message cannot be modified. These blocks must
|
||||
remain as they were in the original response.``
|
||||
|
||||
Root cause under test
|
||||
----------------------
|
||||
With adaptive / interleaved thinking (Claude 4.6+, e.g. Opus 4.8), a single
|
||||
assistant turn can emit content blocks in an interleaved order::
|
||||
|
||||
thinking_1 (signed) · tool_use_1 · thinking_2 (signed) · tool_use_2
|
||||
|
||||
Anthropic signs each thinking block against the turn content that precedes it
|
||||
at its position. ``thinking_2`` is signed with ``tool_use_1`` before it.
|
||||
|
||||
``AnthropicTransport.normalize_response`` (agent/transports/anthropic.py)
|
||||
splits the turn into two *parallel* lists — ``reasoning_details`` (thinking
|
||||
blocks) and ``tool_calls`` (tool_use blocks) — discarding the cross-type
|
||||
ordering. ``run_agent`` stores those as separate fields on the assistant
|
||||
message. On replay, ``_convert_assistant_message`` (agent/anthropic_adapter.py)
|
||||
rebuilds the content as ``[all thinking][text][all tool_use]``, which reorders
|
||||
``thinking_2`` ahead of ``tool_use_1``. The signature no longer matches its
|
||||
original position, so Anthropic rejects the latest assistant message with the
|
||||
400 above.
|
||||
|
||||
This test asserts that an interleaved turn round-trips through
|
||||
normalize_response -> stored message -> convert_messages_to_anthropic with its
|
||||
block order preserved. It FAILS on the current code (documenting the bug) and
|
||||
should PASS once block ordering is preserved on replay.
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.transports import get_transport
|
||||
from agent.anthropic_adapter import convert_messages_to_anthropic
|
||||
|
||||
|
||||
def _thinking_block(text: str, signature: str) -> SimpleNamespace:
|
||||
"""A signed Anthropic thinking block, shaped like the SDK object."""
|
||||
return SimpleNamespace(type="thinking", thinking=text, signature=signature)
|
||||
|
||||
|
||||
def _tool_use_block(block_id: str, name: str, payload: dict) -> SimpleNamespace:
|
||||
return SimpleNamespace(type="tool_use", id=block_id, name=name, input=payload)
|
||||
|
||||
|
||||
def _interleaved_response() -> SimpleNamespace:
|
||||
"""An assistant turn with thinking interleaved between two tool_use blocks."""
|
||||
return SimpleNamespace(
|
||||
content=[
|
||||
_thinking_block("Plan: inspect file A first.", "sig-AAA"),
|
||||
_tool_use_block("toolu_1", "read_file", {"path": "a.py"}),
|
||||
_thinking_block("A looked fine; now inspect B.", "sig-BBB"),
|
||||
_tool_use_block("toolu_2", "read_file", {"path": "b.py"}),
|
||||
],
|
||||
stop_reason="tool_use",
|
||||
usage=None,
|
||||
)
|
||||
|
||||
|
||||
def _stored_assistant_message(normalized) -> dict:
|
||||
"""Reconstruct the OpenAI-style assistant message the way run_agent stores it.
|
||||
|
||||
run_agent.py persists assistant turns as separate fields: content,
|
||||
reasoning_details (from provider_data), and tool_calls. See
|
||||
run_agent.py L1513-1516 and hermes_state.py.
|
||||
"""
|
||||
provider_data = normalized.provider_data or {}
|
||||
tool_calls = []
|
||||
for tc in (normalized.tool_calls or []):
|
||||
tool_calls.append({
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {"name": tc.name, "arguments": tc.arguments},
|
||||
})
|
||||
msg = {
|
||||
"role": "assistant",
|
||||
"content": normalized.content or "",
|
||||
"reasoning_details": provider_data.get("reasoning_details"),
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
# build_assistant_message lifts the verbatim ordered-block channel onto
|
||||
# the stored message; mirror that here.
|
||||
blocks = provider_data.get("anthropic_content_blocks")
|
||||
if blocks:
|
||||
msg["anthropic_content_blocks"] = blocks
|
||||
return msg
|
||||
|
||||
|
||||
def _original_block_order(response) -> list:
|
||||
"""The (type, key) sequence of the original interleaved response."""
|
||||
order = []
|
||||
for b in response.content:
|
||||
if b.type == "thinking":
|
||||
order.append(("thinking", b.signature))
|
||||
elif b.type == "tool_use":
|
||||
order.append(("tool_use", b.id))
|
||||
return order
|
||||
|
||||
|
||||
def _replayed_block_order(assistant_content) -> list:
|
||||
order = []
|
||||
for b in assistant_content:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
if b.get("type") in ("thinking", "redacted_thinking"):
|
||||
order.append(("thinking", b.get("signature")))
|
||||
elif b.get("type") == "tool_use":
|
||||
order.append(("tool_use", b.get("id")))
|
||||
return order
|
||||
|
||||
|
||||
class TestInterleavedThinkingBlockOrder:
|
||||
def test_normalize_response_loses_interleaving(self):
|
||||
"""Confirm the lossy split: normalize_response stores thinking and
|
||||
tool_use in independent fields with no positional linkage."""
|
||||
transport = get_transport("anthropic_messages")
|
||||
normalized = transport.normalize_response(_interleaved_response())
|
||||
|
||||
# Both thinking blocks are captured...
|
||||
details = (normalized.provider_data or {}).get("reasoning_details")
|
||||
assert details is not None and len(details) == 2
|
||||
# ...and both tool calls...
|
||||
assert normalized.tool_calls is not None and len(normalized.tool_calls) == 2
|
||||
# ...but they live in separate fields. There is no single ordered
|
||||
# structure recording that thinking_2 sat between the two tool calls.
|
||||
# (This is the structural precondition for the reorder bug.)
|
||||
|
||||
def test_interleaved_order_preserved_on_replay(self):
|
||||
"""The latest assistant message must replay blocks in their ORIGINAL
|
||||
order, or Anthropic rejects the signed thinking blocks with a 400.
|
||||
|
||||
FAILS on current code: _convert_assistant_message front-loads all
|
||||
thinking blocks, producing
|
||||
thinking_1 · thinking_2 · tool_use_1 · tool_use_2
|
||||
instead of the original
|
||||
thinking_1 · tool_use_1 · thinking_2 · tool_use_2
|
||||
"""
|
||||
response = _interleaved_response()
|
||||
original_order = _original_block_order(response)
|
||||
|
||||
transport = get_transport("anthropic_messages")
|
||||
normalized = transport.normalize_response(response)
|
||||
assistant_msg = _stored_assistant_message(normalized)
|
||||
|
||||
# Build a minimal conversation where this assistant turn is the LATEST
|
||||
# assistant message (the one whose signed blocks are sent verbatim).
|
||||
messages = [
|
||||
{"role": "user", "content": "Inspect a.py and b.py."},
|
||||
assistant_msg,
|
||||
{"role": "tool", "tool_call_id": "toolu_1", "content": "a.py: ok"},
|
||||
{"role": "tool", "tool_call_id": "toolu_2", "content": "b.py: ok"},
|
||||
]
|
||||
|
||||
_system, anthropic_messages = convert_messages_to_anthropic(
|
||||
messages,
|
||||
base_url=None, # direct Anthropic
|
||||
model="claude-opus-4-8", # adaptive thinking family
|
||||
)
|
||||
|
||||
# Find the (latest) assistant message in the converted output.
|
||||
assistant_out = [m for m in anthropic_messages if m.get("role") == "assistant"]
|
||||
assert assistant_out, "no assistant message in converted output"
|
||||
replayed_order = _replayed_block_order(assistant_out[-1]["content"])
|
||||
|
||||
assert replayed_order == original_order, (
|
||||
"Interleaved thinking/tool_use order was not preserved on replay.\n"
|
||||
f" original: {original_order}\n"
|
||||
f" replayed: {replayed_order}\n"
|
||||
"Anthropic signs thinking blocks against their original position; "
|
||||
"reordering invalidates the signature -> HTTP 400 'thinking blocks "
|
||||
"in the latest assistant message cannot be modified'."
|
||||
)
|
||||
|
||||
def test_replay_falls_back_gracefully_without_ordered_blocks(self):
|
||||
"""Without the ordered-block channel, conversion must not crash.
|
||||
|
||||
The channel is intentionally NOT persisted to state.db (in-memory
|
||||
only): a session reloaded from disk after a crash loses the field
|
||||
and falls back to reconstruction. That replay may take one HTTP 400,
|
||||
which the thinking-signature recovery (#43667) absorbs by stripping
|
||||
reasoning_details and retrying. This test pins the fallback shape:
|
||||
conversion still produces a valid assistant message from the
|
||||
parallel reasoning_details + tool_calls fields.
|
||||
"""
|
||||
response = _interleaved_response()
|
||||
transport = get_transport("anthropic_messages")
|
||||
normalized = transport.normalize_response(response)
|
||||
assistant_msg = _stored_assistant_message(normalized)
|
||||
# Simulate a disk reload: the in-memory-only channel is gone.
|
||||
assistant_msg.pop("anthropic_content_blocks", None)
|
||||
|
||||
messages = [
|
||||
assistant_msg,
|
||||
{"role": "tool", "tool_call_id": "toolu_1", "content": "a ok"},
|
||||
{"role": "tool", "tool_call_id": "toolu_2", "content": "b ok"},
|
||||
]
|
||||
_system, anthropic_messages = convert_messages_to_anthropic(
|
||||
messages, base_url=None, model="claude-opus-4-8",
|
||||
)
|
||||
assistant_out = [m for m in anthropic_messages if m.get("role") == "assistant"]
|
||||
assert assistant_out, "no assistant message in converted output"
|
||||
content = assistant_out[-1]["content"]
|
||||
assert isinstance(content, list) and content, "fallback produced empty content"
|
||||
# Reconstruction keeps both tool_use blocks (answered by results).
|
||||
tool_ids = [b.get("id") for b in content if isinstance(b, dict) and b.get("type") == "tool_use"]
|
||||
assert set(tool_ids) == {"toolu_1", "toolu_2"}
|
||||
|
||||
|
||||
class TestInterleavedReplayCredentialRedaction:
|
||||
"""The verbatim-replay fast path must not leak un-redacted secrets.
|
||||
|
||||
anthropic_content_blocks captures each tool_use ``input`` from the RAW API
|
||||
response (normalize_response), which is NOT credential-redacted. The
|
||||
parallel tool_calls[].function.arguments IS redacted at storage time
|
||||
(build_assistant_message, #19798). If the fast path replays the block's raw
|
||||
input verbatim, a secret the model inlined into a tool call rides back onto
|
||||
the wire — even though it is redacted everywhere else in history. The fix
|
||||
re-sources tool_use input from the redacted tool_calls map by id.
|
||||
"""
|
||||
|
||||
def test_tool_use_input_resourced_from_redacted_tool_calls(self):
|
||||
REDACTED = "[REDACTED_SECRET]"
|
||||
# Ordered channel: raw input carries the live secret (as captured from
|
||||
# the unredacted API response).
|
||||
ordered = [
|
||||
{"type": "thinking", "thinking": "Call the API.", "signature": "sig-AAA"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "terminal",
|
||||
"input": {"command": "curl -H 'Authorization: Bearer sk-LIVE-SECRET-123'"},
|
||||
},
|
||||
{"type": "thinking", "thinking": "Now the second call.", "signature": "sig-BBB"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_2",
|
||||
"name": "terminal",
|
||||
"input": {"command": "echo done"},
|
||||
},
|
||||
]
|
||||
# Stored tool_calls: arguments already redacted (the #19798 path).
|
||||
assistant_msg = {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_details": [b for b in ordered if b["type"] == "thinking"],
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "terminal",
|
||||
"arguments": json.dumps(
|
||||
{"command": f"curl -H 'Authorization: Bearer {REDACTED}'"}
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "toolu_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "terminal",
|
||||
"arguments": json.dumps({"command": "echo done"}),
|
||||
},
|
||||
},
|
||||
],
|
||||
"anthropic_content_blocks": ordered,
|
||||
}
|
||||
messages = [
|
||||
{"role": "user", "content": "Hit the API twice."},
|
||||
assistant_msg,
|
||||
{"role": "tool", "tool_call_id": "toolu_1", "content": "200 OK"},
|
||||
{"role": "tool", "tool_call_id": "toolu_2", "content": "done"},
|
||||
]
|
||||
|
||||
_system, anthropic_messages = convert_messages_to_anthropic(
|
||||
messages, base_url=None, model="claude-opus-4-8",
|
||||
)
|
||||
assistant_out = [m for m in anthropic_messages if m.get("role") == "assistant"]
|
||||
assert assistant_out, "no assistant message in converted output"
|
||||
blocks = assistant_out[-1]["content"]
|
||||
|
||||
tool_uses = {b["id"]: b for b in blocks if b.get("type") == "tool_use"}
|
||||
assert set(tool_uses) == {"toolu_1", "toolu_2"}, "tool_use blocks missing/renamed"
|
||||
|
||||
# The replayed input must be the REDACTED value, not the live secret.
|
||||
replayed_cmd = tool_uses["toolu_1"]["input"]["command"]
|
||||
assert "sk-LIVE-SECRET-123" not in replayed_cmd, (
|
||||
"Un-redacted secret leaked onto the wire via the verbatim-replay "
|
||||
"fast path. tool_use input must be re-sourced from the redacted "
|
||||
"tool_calls map, not the raw captured block."
|
||||
)
|
||||
assert REDACTED in replayed_cmd
|
||||
|
||||
# Interleave order is still preserved (the reason the channel exists).
|
||||
order = [
|
||||
("thinking", b.get("signature")) if b.get("type") == "thinking"
|
||||
else ("tool_use", b.get("id"))
|
||||
for b in blocks if b.get("type") in ("thinking", "tool_use")
|
||||
]
|
||||
assert order == [
|
||||
("thinking", "sig-AAA"),
|
||||
("tool_use", "toolu_1"),
|
||||
("thinking", "sig-BBB"),
|
||||
("tool_use", "toolu_2"),
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
|
|
@ -1471,3 +1471,127 @@ class TestCallConverseInvalidatesOnStaleError:
|
|||
)
|
||||
|
||||
assert _bedrock_runtime_client_cache.get("us-east-1") is live_client
|
||||
|
||||
|
||||
class TestStreamingAccessDeniedDetection:
|
||||
"""is_streaming_access_denied_error() recognizes IAM denials of
|
||||
bedrock:InvokeModelWithResponseStream (InvokeModel-only policies)."""
|
||||
|
||||
def _denied_client_error(self):
|
||||
from botocore.exceptions import ClientError
|
||||
return ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "AccessDeniedException",
|
||||
"Message": (
|
||||
"User: arn:aws:iam::123456789012:user/x is not "
|
||||
"authorized to perform: "
|
||||
"bedrock:InvokeModelWithResponseStream on resource: "
|
||||
"arn:aws:bedrock:us-east-1::foundation-model/"
|
||||
"anthropic.claude-3-sonnet-20240229-v1:0"
|
||||
),
|
||||
}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
|
||||
def test_matches_access_denied_client_error(self):
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
assert is_streaming_access_denied_error(self._denied_client_error()) is True
|
||||
|
||||
def test_ignores_access_denied_for_other_actions(self):
|
||||
"""AccessDenied on InvokeModel itself is NOT a streaming-only denial."""
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
from botocore.exceptions import ClientError
|
||||
exc = ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "AccessDeniedException",
|
||||
"Message": (
|
||||
"User is not authorized to perform: bedrock:InvokeModel"
|
||||
),
|
||||
}
|
||||
},
|
||||
operation_name="Converse",
|
||||
)
|
||||
assert is_streaming_access_denied_error(exc) is False
|
||||
|
||||
def test_ignores_validation_error_mentioning_action(self):
|
||||
"""Non-authz ClientErrors don't match even if the action name appears."""
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
from botocore.exceptions import ClientError
|
||||
exc = ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "ValidationException",
|
||||
"Message": "InvokeModelWithResponseStream input malformed",
|
||||
}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
assert is_streaming_access_denied_error(exc) is False
|
||||
|
||||
def test_matches_wrapped_sdk_permission_error(self):
|
||||
"""Non-ClientError wrappers (AnthropicBedrock SDK) match on message."""
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
exc = RuntimeError(
|
||||
"PermissionDeniedError: user is not authorized to perform: "
|
||||
"bedrock:InvokeModelWithResponseStream"
|
||||
)
|
||||
assert is_streaming_access_denied_error(exc) is True
|
||||
|
||||
def test_ignores_unrelated_errors(self):
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
assert is_streaming_access_denied_error(ValueError("boom")) is False
|
||||
assert is_streaming_access_denied_error(
|
||||
RuntimeError("stream not supported")
|
||||
) is False
|
||||
|
||||
|
||||
class TestCallConverseStreamIamFallback:
|
||||
"""call_converse_stream() falls back to converse() when IAM denies the
|
||||
streaming action — InvokeModel-only policies keep working."""
|
||||
|
||||
def test_falls_back_to_converse_on_streaming_denial(self):
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
|
||||
from agent.bedrock_adapter import (
|
||||
_bedrock_runtime_client_cache,
|
||||
call_converse_stream,
|
||||
reset_client_cache,
|
||||
)
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
reset_client_cache()
|
||||
client = MagicMock()
|
||||
client.converse_stream.side_effect = ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "AccessDeniedException",
|
||||
"Message": (
|
||||
"User is not authorized to perform: "
|
||||
"bedrock:InvokeModelWithResponseStream"
|
||||
),
|
||||
}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
client.converse.return_value = {
|
||||
"output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2},
|
||||
}
|
||||
_bedrock_runtime_client_cache["us-east-1"] = client
|
||||
|
||||
result = call_converse_stream(
|
||||
region="us-east-1",
|
||||
model="anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
client.converse.assert_called_once()
|
||||
assert result.choices[0].message.content == "hi"
|
||||
# Not a stale connection — client stays cached.
|
||||
assert _bedrock_runtime_client_cache.get("us-east-1") is client
|
||||
|
|
|
|||
405
tests/agent/test_coding_context.py
Normal file
405
tests/agent/test_coding_context.py
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
"""Tests for agent.coding_context — RuntimeMode seam, resolver, toolset, git probe."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import coding_context as cc
|
||||
|
||||
|
||||
def _git_init(path):
|
||||
env = {
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
}
|
||||
for args in (
|
||||
["init", "-q", "-b", "main"],
|
||||
["commit", "-q", "--allow-empty", "-m", "init commit"],
|
||||
):
|
||||
subprocess.run(["git", "-C", str(path), *args], check=True, env={**env, "HOME": str(path)})
|
||||
|
||||
|
||||
# ── resolver ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestIsCodingContext:
|
||||
def test_off_never_activates(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
cfg = {"agent": {"coding_context": "off"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
|
||||
|
||||
def test_on_forces_even_without_git(self, tmp_path):
|
||||
cfg = {"agent": {"coding_context": "on"}}
|
||||
assert cc.is_coding_context(platform="telegram", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_auto_requires_git_repo(self, tmp_path):
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
|
||||
_git_init(tmp_path)
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_auto_skips_messaging_surfaces(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="discord", cwd=tmp_path, config=cfg) is False
|
||||
assert cc.is_coding_context(platform="tui", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_default_mode_is_auto(self, tmp_path):
|
||||
# Unknown/missing value normalizes to auto.
|
||||
_git_init(tmp_path)
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config={}) is True
|
||||
|
||||
|
||||
# ── toolset substitution ────────────────────────────────────────────────────
|
||||
|
||||
class TestCodingSelection:
|
||||
def test_selects_coding_under_focus(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
cfg = {"agent": {"coding_context": "focus"}}
|
||||
out = cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg)
|
||||
assert out is not None
|
||||
assert out[0] == cc.CODING_TOOLSET
|
||||
|
||||
def test_auto_is_prompt_only(self, tmp_path):
|
||||
# Default posture must never override the user's configured toolsets —
|
||||
# off-by-default toolsets are already off, and explicit opt-ins
|
||||
# (image-gen, spotify, …) survive entering a code workspace.
|
||||
_git_init(tmp_path)
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
|
||||
# …while the prompt posture is still active.
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_on_is_prompt_only(self, tmp_path):
|
||||
cfg = {"agent": {"coding_context": "on"}}
|
||||
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_focus_requires_workspace(self, tmp_path):
|
||||
# focus inherits auto's detection gate — bare dir stays general.
|
||||
cfg = {"agent": {"coding_context": "focus"}}
|
||||
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
|
||||
|
||||
def test_none_when_inactive(self, tmp_path):
|
||||
cfg = {"agent": {"coding_context": "off"}}
|
||||
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
|
||||
|
||||
def test_coding_toolset_is_registered(self):
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
tools = resolve_toolset(cc.CODING_TOOLSET)
|
||||
# Coding essentials present…
|
||||
for t in ("read_file", "write_file", "patch", "search_files", "terminal", "todo"):
|
||||
assert t in tools
|
||||
# …and the noise is gone.
|
||||
for t in ("send_message", "text_to_speech", "image_generate", "computer_use"):
|
||||
assert t not in tools
|
||||
|
||||
|
||||
# ── git/workspace probe ─────────────────────────────────────────────────────
|
||||
|
||||
class TestWorkspaceBlock:
|
||||
def test_empty_outside_repo(self, tmp_path):
|
||||
assert cc.build_coding_workspace_block(tmp_path) == ""
|
||||
|
||||
def test_reports_branch_and_clean_status(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "Workspace" in block
|
||||
assert f"Root: {tmp_path.resolve()}" in block or "Root:" in block
|
||||
assert "Branch: main" in block
|
||||
assert "Status: clean" in block
|
||||
assert "init commit" in block
|
||||
|
||||
def test_reports_dirty_counts(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "untracked.txt").write_text("hi")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "untracked" in block
|
||||
assert "clean" not in block.split("Status:")[1].splitlines()[0]
|
||||
|
||||
|
||||
# ── project facts (verify-loop detection) ───────────────────────────────────
|
||||
|
||||
class TestProjectFacts:
|
||||
def test_package_json_scripts_surface_verify_commands(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "package.json").write_text(
|
||||
json.dumps({"scripts": {"test": "vitest", "lint": "eslint .", "dev": "vite"}})
|
||||
)
|
||||
(tmp_path / "pnpm-lock.yaml").write_text("")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "Project: package.json (pnpm)" in block
|
||||
assert "pnpm run test" in block and "pnpm run lint" in block
|
||||
# Non-verify scripts (dev servers, …) stay out of the snapshot.
|
||||
assert "run dev" not in block
|
||||
|
||||
def test_pytest_config_and_run_tests_script(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\n")
|
||||
scripts = tmp_path / "scripts"
|
||||
scripts.mkdir()
|
||||
(scripts / "run_tests.sh").write_text("#!/bin/sh\n")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "scripts/run_tests.sh" in block
|
||||
assert "pytest" in block.split("Verify:")[1]
|
||||
|
||||
def test_makefile_verify_targets_only(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "Makefile").write_text("test:\n\tgo test ./...\n\ndeploy:\n\t./deploy.sh\n")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "make test" in block
|
||||
assert "make deploy" not in block
|
||||
|
||||
def test_context_files_listed(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "AGENTS.md").write_text("# rules")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "Context files: AGENTS.md" in block
|
||||
|
||||
def test_marker_only_project_gets_snapshot_without_git(self, tmp_path):
|
||||
# A non-git project (manifest only) still gets a workspace snapshot —
|
||||
# just without the git lines.
|
||||
(tmp_path / "package.json").write_text("{}")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert f"Root: {tmp_path.resolve()}" in block
|
||||
assert "package.json" in block
|
||||
assert "Branch:" not in block and "Status:" not in block
|
||||
|
||||
def test_malformed_package_json_is_ignored(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "package.json").write_text("{not json")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "Project: package.json" in block
|
||||
assert "Verify:" not in block
|
||||
|
||||
|
||||
# ── $HOME dotfiles guard ────────────────────────────────────────────────────
|
||||
|
||||
class TestHomeDotfilesGuard:
|
||||
def test_dotfiles_repo_at_home_is_not_coding(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
_git_init(home)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=home, config=cfg) is False
|
||||
# …and a plain subdirectory of the dotfiles repo stays general too.
|
||||
docs = home / "Documents"
|
||||
docs.mkdir()
|
||||
assert cc.is_coding_context(platform="cli", cwd=docs, config=cfg) is False
|
||||
|
||||
def test_marker_at_home_is_not_a_project_signal(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
(home / "Makefile").write_text("all:\n")
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=home, config=cfg) is False
|
||||
|
||||
def test_real_project_under_dotfiles_home_still_detects(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
_git_init(home)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
proj = home / "www" / "app"
|
||||
proj.mkdir(parents=True)
|
||||
(proj / "package.json").write_text("{}")
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=proj, config=cfg) is True
|
||||
|
||||
def test_on_mode_bypasses_the_guard(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
cfg = {"agent": {"coding_context": "on"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=home, config=cfg) is True
|
||||
|
||||
|
||||
# ── prompt assembly integration ─────────────────────────────────────────────
|
||||
|
||||
class TestStatusParsing:
|
||||
def test_parse_status_counts_and_branch(self):
|
||||
porcelain = (
|
||||
"# branch.head feature\n"
|
||||
"# branch.upstream origin/feature\n"
|
||||
"# branch.ab +2 -1\n"
|
||||
"1 M. N... 100644 100644 100644 aaa bbb staged.py\n"
|
||||
"1 .M N... 100644 100644 100644 ccc ddd modified.py\n"
|
||||
"? new.py\n"
|
||||
"u UU N... 1 2 3 abc def conflict.py\n"
|
||||
)
|
||||
branch, counts = cc._parse_status(porcelain)
|
||||
assert branch["head"] == "feature"
|
||||
assert branch["upstream"] == "origin/feature"
|
||||
assert branch["ahead"] == "2" and branch["behind"] == "1"
|
||||
assert counts["staged"] == 1
|
||||
assert counts["modified"] == 1
|
||||
assert counts["untracked"] == 1
|
||||
assert counts["conflicts"] == 1
|
||||
|
||||
|
||||
# ── RuntimeMode seam ────────────────────────────────────────────────────────
|
||||
|
||||
class TestRuntimeMode:
|
||||
def test_resolves_coding_in_repo(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
|
||||
assert mode.is_coding is True
|
||||
assert mode.kind == "coding"
|
||||
assert mode.profile is cc.CODING_PROFILE
|
||||
|
||||
def test_resolves_general_outside_workspace(self, tmp_path):
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
|
||||
assert mode.is_coding is False
|
||||
assert mode.kind == "general"
|
||||
# General posture pins no toolset and injects no blocks.
|
||||
assert mode.toolset_selection() is None
|
||||
assert mode.system_blocks() == []
|
||||
|
||||
def test_is_frozen(self, tmp_path):
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
|
||||
with pytest.raises(Exception):
|
||||
mode.profile = cc.CODING_PROFILE # type: ignore[misc]
|
||||
|
||||
def test_system_blocks_include_brief_and_workspace(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "on"}})
|
||||
blocks = mode.system_blocks()
|
||||
assert any("coding agent" in b for b in blocks)
|
||||
assert any("Workspace" in b for b in blocks)
|
||||
|
||||
def test_toolset_selection_gated_on_focus(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
focus = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "focus"}})
|
||||
sel = focus.toolset_selection()
|
||||
assert sel and sel[0] == cc.CODING_TOOLSET
|
||||
# auto/on resolve the coding profile but stay prompt-only.
|
||||
for raw in ("auto", "on"):
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": raw}})
|
||||
assert mode.is_coding is True
|
||||
assert mode.toolset_selection() is None
|
||||
|
||||
|
||||
# ── edit-format steering (per-model harness tuning) ──────────────────────────
|
||||
|
||||
class TestEditFormatSteering:
|
||||
def test_family_detection(self):
|
||||
assert cc._model_family("openai/gpt-5.4") == "patch"
|
||||
assert cc._model_family("openai/codex-mini") == "patch"
|
||||
assert cc._model_family("anthropic/claude-opus-4.8") == "replace"
|
||||
assert cc._model_family("anthropic/claude-sonnet-4") == "replace"
|
||||
# Gemini + open-weight coding models (RL'd on str_replace-style
|
||||
# editors) steer to replace, not neutral.
|
||||
for m in (
|
||||
"google/gemini-3-pro", "deepseek-v3.2", "qwen3-coder",
|
||||
"moonshot/kimi-k2", "zai/glm-4.6", "nousresearch/hermes-4-405b",
|
||||
):
|
||||
assert cc._model_family(m) == "replace"
|
||||
# Unknown family and no model both fall through to neutral wording.
|
||||
assert cc._model_family("acme/foo-1") is None
|
||||
assert cc._model_family(None) is None
|
||||
assert cc._model_family("") is None
|
||||
|
||||
def test_openai_family_gets_v4a_nudge(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path,
|
||||
config={"agent": {"coding_context": "on"}}, model="openai/gpt-5.4",
|
||||
)
|
||||
brief = mode.system_blocks()[0]
|
||||
assert "mode='patch'" in brief
|
||||
assert "V4A" in brief
|
||||
assert "write_file" in brief # new files authored, not patched
|
||||
|
||||
def test_anthropic_family_gets_replace_nudge(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path,
|
||||
config={"agent": {"coding_context": "on"}},
|
||||
model="anthropic/claude-opus-4.8",
|
||||
)
|
||||
brief = mode.system_blocks()[0]
|
||||
assert "mode='replace'" in brief
|
||||
assert "write_file" in brief # new files authored, not patched
|
||||
|
||||
def test_unknown_model_keeps_neutral_brief(self, tmp_path):
|
||||
# No edit-format line appended — brief equals the bare profile guidance.
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path,
|
||||
config={"agent": {"coding_context": "on"}}, model="acme/foo-1",
|
||||
)
|
||||
assert mode.system_blocks()[0] == cc.CODING_AGENT_GUIDANCE
|
||||
|
||||
def test_no_model_keeps_neutral_brief(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path,
|
||||
config={"agent": {"coding_context": "on"}},
|
||||
)
|
||||
assert mode.system_blocks()[0] == cc.CODING_AGENT_GUIDANCE
|
||||
|
||||
def test_general_posture_emits_nothing_regardless_of_model(self, tmp_path):
|
||||
# Edit steering only fires inside the coding posture.
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="telegram", cwd=tmp_path, config={}, model="openai/gpt-5.4",
|
||||
)
|
||||
assert mode.system_blocks() == []
|
||||
|
||||
|
||||
# ── profile registry ────────────────────────────────────────────────────────
|
||||
|
||||
class TestProfiles:
|
||||
def test_registered_profiles(self):
|
||||
assert cc.get_profile("coding") is cc.CODING_PROFILE
|
||||
assert cc.get_profile("general") is cc.GENERAL_PROFILE
|
||||
|
||||
def test_unknown_profile_falls_back_to_general(self):
|
||||
assert cc.get_profile("nonsense") is cc.GENERAL_PROFILE
|
||||
|
||||
def test_coding_profile_shape(self):
|
||||
# The coding profile declares the seams other domains read.
|
||||
assert cc.CODING_PROFILE.toolset == cc.CODING_TOOLSET
|
||||
assert cc.CODING_PROFILE.guidance
|
||||
assert cc.CODING_PROFILE.model_hint == "coding"
|
||||
# General is inert.
|
||||
assert cc.GENERAL_PROFILE.toolset is None
|
||||
assert cc.GENERAL_PROFILE.guidance == ""
|
||||
|
||||
def test_skill_pruning_scoped_to_coding_posture(self, tmp_path):
|
||||
# Coding posture hides clearly-non-coding categories; coding-adjacent
|
||||
# ones stay visible (deny-list semantics).
|
||||
_git_init(tmp_path)
|
||||
coding = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
|
||||
hidden = coding.hidden_skill_categories()
|
||||
assert "social-media" in hidden and "smart-home" in hidden
|
||||
for kept in ("github", "devops", "software-development", "data-science"):
|
||||
assert kept not in hidden
|
||||
# General posture hides nothing.
|
||||
general = cc.resolve_runtime_mode(
|
||||
platform="telegram", cwd=tmp_path, config={}
|
||||
)
|
||||
assert general.hidden_skill_categories() == frozenset()
|
||||
|
||||
|
||||
# ── detection signals ───────────────────────────────────────────────────────
|
||||
|
||||
class TestDetection:
|
||||
@pytest.mark.parametrize("marker", ["pyproject.toml", "package.json", "go.mod", "AGENTS.md"])
|
||||
def test_project_manifest_triggers_without_git(self, tmp_path, marker):
|
||||
(tmp_path / marker).write_text("x")
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_marker_in_parent_counts_from_subdir(self, tmp_path):
|
||||
(tmp_path / "pyproject.toml").write_text("x")
|
||||
sub = tmp_path / "src" / "pkg"
|
||||
sub.mkdir(parents=True)
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=sub, config=cfg) is True
|
||||
|
||||
def test_bare_dir_is_not_coding(self, tmp_path):
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
|
||||
|
|
@ -12,6 +12,7 @@ from agent.display import (
|
|||
set_tool_preview_max_len,
|
||||
_render_inline_unified_diff,
|
||||
_summarize_rendered_diff_sections,
|
||||
_used_free_parallel,
|
||||
render_edit_diff_with_delta,
|
||||
)
|
||||
|
||||
|
|
@ -171,6 +172,46 @@ class TestCuteToolMessagePreviewLength:
|
|||
assert "[error]" not in line
|
||||
|
||||
|
||||
class TestWebProviderLabel:
|
||||
"""The free-path "Parallel search"/"Parallel fetch" verb labeling."""
|
||||
|
||||
def test_free_search_verb_is_parallel(self):
|
||||
result = json.dumps({"success": True, "data": {"web": []}, "provider": "parallel"})
|
||||
line = get_cute_tool_message("web_search", {"query": "hello"}, 0.1, result=result)
|
||||
assert "Parallel search" in line
|
||||
assert "hello" in line
|
||||
|
||||
def test_paid_search_verb_is_plain(self):
|
||||
result = json.dumps({"success": True, "data": {"web": [{"url": "u"}]}})
|
||||
line = get_cute_tool_message("web_search", {"query": "hi"}, 0.1, result=result)
|
||||
assert "Parallel" not in line
|
||||
assert "search" in line
|
||||
|
||||
def test_missing_result_verb_is_plain(self):
|
||||
line = get_cute_tool_message("web_search", {"query": "hello"}, 0.1)
|
||||
assert "Parallel" not in line
|
||||
assert "search" in line
|
||||
|
||||
def test_helper_is_parallel_free_specific(self):
|
||||
# Only Parallel's free MCP path marks results; nothing else does.
|
||||
assert _used_free_parallel(json.dumps({"provider": "parallel"})) is True
|
||||
assert _used_free_parallel(json.dumps({"provider": "exa"})) is False
|
||||
assert _used_free_parallel(json.dumps({"provider": "firecrawl"})) is False
|
||||
assert _used_free_parallel(json.dumps({"success": True, "data": {}})) is False
|
||||
assert _used_free_parallel('not json') is False
|
||||
assert _used_free_parallel(None) is False
|
||||
|
||||
def test_free_extract_verb_is_parallel(self):
|
||||
result = json.dumps({"results": [{"url": "u", "content": "x"}], "provider": "parallel"})
|
||||
line = get_cute_tool_message("web_extract", {"urls": ["https://a.test"]}, 0.1, result=result)
|
||||
assert "Parallel fetch" in line
|
||||
|
||||
def test_paid_extract_verb_is_plain(self):
|
||||
result = json.dumps({"results": [{"url": "u", "content": "x"}]})
|
||||
line = get_cute_tool_message("web_extract", {"urls": ["https://a.test"]}, 0.1, result=result)
|
||||
assert "Parallel" not in line
|
||||
|
||||
|
||||
class TestEditDiffPreview:
|
||||
def test_extract_edit_diff_for_patch(self):
|
||||
diff = extract_edit_diff("patch", '{"success": true, "diff": "--- a/x\\n+++ b/x\\n"}')
|
||||
|
|
|
|||
|
|
@ -661,6 +661,42 @@ class TestClassifyApiError:
|
|||
# Without "thinking" in the message, it shouldn't be thinking_signature
|
||||
assert result.reason != FailoverReason.thinking_signature
|
||||
|
||||
def test_anthropic_thinking_blocks_cannot_be_modified(self):
|
||||
"""Frozen-block mutation 400 (no 'signature' token) must route to
|
||||
thinking_signature recovery, not hard-abort. Regression for the
|
||||
real-world error: latest-assistant thinking blocks 'cannot be
|
||||
modified' after upstream message mutation."""
|
||||
e = MockAPIError(
|
||||
"messages.73.content.10: `thinking` or `redacted_thinking` blocks "
|
||||
"in the latest assistant message cannot be modified. These blocks "
|
||||
"must remain as they were in the original response.",
|
||||
status_code=400,
|
||||
)
|
||||
result = classify_api_error(e, provider="anthropic")
|
||||
assert result.reason == FailoverReason.thinking_signature
|
||||
assert result.retryable is True
|
||||
|
||||
def test_anthropic_thinking_cannot_be_modified_via_openrouter(self):
|
||||
"""Same frozen-block error proxied through OpenRouter must also be
|
||||
caught (provider is not gated)."""
|
||||
e = MockAPIError(
|
||||
"`thinking` or `redacted_thinking` blocks in the latest assistant "
|
||||
"message cannot be modified.",
|
||||
status_code=400,
|
||||
)
|
||||
result = classify_api_error(e, provider="openrouter")
|
||||
assert result.reason == FailoverReason.thinking_signature
|
||||
assert result.retryable is True
|
||||
|
||||
def test_400_cannot_be_modified_without_thinking_not_classified(self):
|
||||
"""A 400 'cannot be modified' that has nothing to do with thinking
|
||||
blocks must NOT be swept into thinking_signature recovery."""
|
||||
e = MockAPIError(
|
||||
"this field cannot be modified after creation", status_code=400,
|
||||
)
|
||||
result = classify_api_error(e, provider="anthropic", approx_tokens=0)
|
||||
assert result.reason != FailoverReason.thinking_signature
|
||||
|
||||
def test_invalid_encrypted_content_classified_as_retryable_replay_failure(self):
|
||||
body = {
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -276,6 +276,42 @@ class TestBuildSkillsSystemPrompt:
|
|||
# "search" should appear only once per category
|
||||
assert result.count("- search") == 1
|
||||
|
||||
def test_hidden_categories_pruned_with_note(self, monkeypatch, tmp_path):
|
||||
"""Posture-driven pruning drops whole categories and discloses it."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
for cat, name in (("social-media", "tweet-stuff"), ("github", "pr-review")):
|
||||
d = tmp_path / "skills" / cat / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: Does {name} things\n---\n"
|
||||
)
|
||||
|
||||
result = build_skills_system_prompt(
|
||||
hidden_categories=frozenset({"social-media"})
|
||||
)
|
||||
assert "pr-review" in result
|
||||
assert "tweet-stuff" not in result
|
||||
# Disclosure note so the model knows the full catalog exists.
|
||||
assert "skills_list" in result
|
||||
|
||||
def test_hidden_categories_prune_nested_and_miss_cache_separately(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
d = tmp_path / "skills" / "social-media" / "twitter" / "thread-writer"
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
"---\nname: thread-writer\ndescription: Write threads\n---\n"
|
||||
)
|
||||
# Nested category ("social-media/twitter") pruned via its parent.
|
||||
pruned = build_skills_system_prompt(
|
||||
hidden_categories=frozenset({"social-media"})
|
||||
)
|
||||
assert "thread-writer" not in pruned
|
||||
# Unfiltered call must not be served from the filtered cache entry.
|
||||
full = build_skills_system_prompt()
|
||||
assert "thread-writer" in full
|
||||
|
||||
def test_excludes_incompatible_platform_skills(self, monkeypatch, tmp_path):
|
||||
"""Skills with platforms: [macos] should not appear on Linux."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
|
|
|||
111
tests/agent/test_stream_read_timeout_floor.py
Normal file
111
tests/agent/test_stream_read_timeout_floor.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Stream read timeout must never preempt the stale-stream detector.
|
||||
|
||||
Reasoning models (e.g. Opus) routinely pause mid-stream for minutes during
|
||||
extended thinking. The stale-stream detector is deliberately scaled up to
|
||||
tolerate this (180s base, raised to 240s/300s for large contexts). The httpx
|
||||
socket read timeout, however, defaulted to a flat 120s for cloud providers and
|
||||
fired *first* — tearing down a healthy reasoning stream before the stale
|
||||
detector (which owns retry + diagnostics) could act.
|
||||
|
||||
These tests pin the invariant: for a cloud provider on the default read
|
||||
timeout, the httpx socket read timeout is floored at the stale-stream timeout
|
||||
so it can never fire before the detector. They mirror the inline logic in
|
||||
``agent/chat_completion_helpers.py`` (the real builder lives deep inside a
|
||||
worker thread, so — like ``test_local_stream_timeout.py`` — the resolution is
|
||||
reproduced here rather than driven end-to-end).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
|
||||
|
||||
def _resolve_stale_timeout(base_url, est_tokens, stale_base=180.0):
|
||||
"""Mirror of the stale-stream detector resolution."""
|
||||
if stale_base == 180.0 and base_url and is_local_endpoint(base_url):
|
||||
return float("inf") # detector disabled for local providers
|
||||
if est_tokens > 100_000:
|
||||
return max(stale_base, 300.0)
|
||||
if est_tokens > 50_000:
|
||||
return max(stale_base, 240.0)
|
||||
return stale_base
|
||||
|
||||
|
||||
def _resolve_read_timeout(base_url, stale_timeout, base_timeout=1800.0):
|
||||
"""Mirror of the httpx socket read-timeout builder (cloud branch)."""
|
||||
read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
if read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
|
||||
read_timeout = base_timeout
|
||||
elif (
|
||||
read_timeout == 120.0
|
||||
and stale_timeout is not None
|
||||
and stale_timeout != float("inf")
|
||||
and stale_timeout > read_timeout
|
||||
):
|
||||
read_timeout = stale_timeout
|
||||
return read_timeout
|
||||
|
||||
|
||||
CLOUD_URLS = [
|
||||
"https://api.githubcopilot.com",
|
||||
"https://api.openai.com",
|
||||
"https://openrouter.ai/api",
|
||||
"https://api.anthropic.com",
|
||||
]
|
||||
|
||||
|
||||
class TestCloudReadTimeoutFloor:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_env(self):
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.delenv("HERMES_STREAM_READ_TIMEOUT", raising=False)
|
||||
yield
|
||||
|
||||
@pytest.mark.parametrize("base_url", CLOUD_URLS)
|
||||
@pytest.mark.parametrize("est_tokens", [0, 10_000, 60_000, 150_000])
|
||||
def test_read_timeout_never_below_stale(self, base_url, est_tokens):
|
||||
"""Core invariant: the socket read timeout >= the stale detector."""
|
||||
stale = _resolve_stale_timeout(base_url, est_tokens)
|
||||
read = _resolve_read_timeout(base_url, stale)
|
||||
assert read >= stale
|
||||
|
||||
@pytest.mark.parametrize("base_url", CLOUD_URLS)
|
||||
def test_small_context_floored_to_stale_base(self, base_url):
|
||||
"""Reported case: ~120s timeouts on Copilot are raised to the 180s base."""
|
||||
stale = _resolve_stale_timeout(base_url, est_tokens=37_000)
|
||||
read = _resolve_read_timeout(base_url, stale)
|
||||
assert read == 180.0
|
||||
|
||||
@pytest.mark.parametrize("base_url", CLOUD_URLS)
|
||||
def test_large_context_tracks_scaled_stale(self, base_url):
|
||||
"""Big contexts scale the stale detector; the read timeout follows."""
|
||||
assert _resolve_read_timeout(base_url, _resolve_stale_timeout(base_url, 60_000)) == 240.0
|
||||
assert _resolve_read_timeout(base_url, _resolve_stale_timeout(base_url, 150_000)) == 300.0
|
||||
|
||||
def test_user_override_is_respected(self):
|
||||
"""An explicit HERMES_STREAM_READ_TIMEOUT is never overridden by the floor."""
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setenv("HERMES_STREAM_READ_TIMEOUT", "90")
|
||||
stale = _resolve_stale_timeout("https://api.githubcopilot.com", est_tokens=0)
|
||||
assert _resolve_read_timeout("https://api.githubcopilot.com", stale) == 90.0
|
||||
|
||||
|
||||
class TestLocalUnaffected:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_env(self):
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.delenv("HERMES_STREAM_READ_TIMEOUT", raising=False)
|
||||
yield
|
||||
|
||||
def test_local_still_raised_to_base(self):
|
||||
"""Local providers keep their existing behavior (raise to base timeout)."""
|
||||
stale = _resolve_stale_timeout("http://localhost:11434", est_tokens=0)
|
||||
assert stale == float("inf") # detector disabled for local
|
||||
read = _resolve_read_timeout("http://localhost:11434", stale)
|
||||
assert read == 1800.0 # not clamped by inf
|
||||
|
||||
def test_stale_none_falls_back_to_default(self):
|
||||
"""If the stale value is unresolved, the read timeout keeps its default."""
|
||||
assert _resolve_read_timeout("https://api.githubcopilot.com", None) == 120.0
|
||||
|
|
@ -55,3 +55,44 @@ class TestContextFileCwd:
|
|||
def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
assert _captured_context_cwd(_make_agent()) == tmp_path
|
||||
|
||||
|
||||
def _stable_prompt(agent):
|
||||
with (
|
||||
patch("run_agent.load_soul_md", return_value=""),
|
||||
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
||||
patch("run_agent.build_environment_hints", return_value=""),
|
||||
patch("run_agent.build_context_files_prompt", return_value=""),
|
||||
):
|
||||
return build_system_prompt_parts(agent)["stable"]
|
||||
|
||||
|
||||
class TestCodingContextBlock:
|
||||
def test_injected_when_active(self, monkeypatch, tmp_path):
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True)
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
agent = _make_agent(valid_tool_names=["read_file"], platform="cli")
|
||||
stable = _stable_prompt(agent)
|
||||
assert "coding agent" in stable
|
||||
assert "Workspace" in stable
|
||||
|
||||
def test_absent_when_off(self, monkeypatch, tmp_path):
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True)
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
agent = _make_agent(valid_tool_names=["read_file"], platform="cli")
|
||||
# Drive the real path: force the resolved mode to "off" via config.
|
||||
with patch("agent.coding_context._coding_mode", return_value="off"):
|
||||
stable = _stable_prompt(agent)
|
||||
assert "coding agent" not in stable
|
||||
|
||||
def test_absent_without_tools(self, monkeypatch, tmp_path):
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True)
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
agent = _make_agent(valid_tool_names=[], platform="cli")
|
||||
assert "coding agent" not in _stable_prompt(agent)
|
||||
|
|
|
|||
|
|
@ -676,3 +676,54 @@ class TestStatusBarWidthSource:
|
|||
mock_get_app.assert_not_called()
|
||||
mock_shutil.assert_not_called()
|
||||
assert len(text) > 0
|
||||
|
||||
|
||||
class TestIdleSinceLastTurn:
|
||||
"""Time-since-last-final-agent-response read-out on the status bar."""
|
||||
|
||||
def test_hidden_before_first_turn(self):
|
||||
assert HermesCLI._format_idle_since(None, turn_live=False) == ""
|
||||
|
||||
def test_hidden_while_turn_is_live(self):
|
||||
assert HermesCLI._format_idle_since(time.time() - 30, turn_live=True) == ""
|
||||
|
||||
def test_shows_compact_idle_time_after_turn(self):
|
||||
label = HermesCLI._format_idle_since(time.time() - 42, turn_live=False)
|
||||
assert label.startswith("✓ ")
|
||||
assert label == "✓ 42s"
|
||||
|
||||
def test_scales_to_minutes(self):
|
||||
label = HermesCLI._format_idle_since(time.time() - 3 * 60, turn_live=False)
|
||||
assert label == "✓ 3m"
|
||||
|
||||
def test_snapshot_carries_idle_since(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._last_turn_finished_at = time.time() - 10
|
||||
cli_obj._prompt_start_time = None
|
||||
cli_obj._prompt_duration = 5.0
|
||||
snapshot = cli_obj._get_status_bar_snapshot()
|
||||
assert snapshot["idle_since"].startswith("✓ ")
|
||||
|
||||
def test_snapshot_idle_empty_during_live_turn(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._last_turn_finished_at = time.time() - 10
|
||||
cli_obj._prompt_start_time = time.time()
|
||||
cli_obj._prompt_duration = 0.0
|
||||
snapshot = cli_obj._get_status_bar_snapshot()
|
||||
assert snapshot["idle_since"] == ""
|
||||
|
||||
def test_wide_status_bar_text_includes_idle(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=12_450,
|
||||
context_length=200_000,
|
||||
)
|
||||
cli_obj._last_turn_finished_at = time.time() - 42
|
||||
cli_obj._prompt_start_time = None
|
||||
cli_obj._prompt_duration = 7.0
|
||||
text = cli_obj._build_status_bar_text(width=160)
|
||||
assert "✓ 42s" in text
|
||||
|
|
|
|||
|
|
@ -1,449 +0,0 @@
|
|||
"""Tests for per-job profile support in cron jobs.
|
||||
|
||||
Covers data-layer validation/storage, cronjob tool plumbing, scheduler runtime
|
||||
HERMES_HOME scoping, and tick() serialization for profile jobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolated_cron_profile_home(tmp_path, monkeypatch):
|
||||
"""Create an isolated Hermes root with a named profile and temp cron store."""
|
||||
root = tmp_path / "hermes-root"
|
||||
profile_home = root / "profiles" / "support"
|
||||
profile_home.mkdir(parents=True)
|
||||
(root / "cron").mkdir(parents=True)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
monkeypatch.setattr("cron.jobs.CRON_DIR", root / "cron")
|
||||
monkeypatch.setattr("cron.jobs.JOBS_FILE", root / "cron" / "jobs.json")
|
||||
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", root / "cron" / "output")
|
||||
|
||||
return root, profile_home
|
||||
|
||||
|
||||
class TestNormalizeProfile:
|
||||
def test_none_and_empty_return_none(self, isolated_cron_profile_home):
|
||||
from cron.jobs import _normalize_profile
|
||||
|
||||
assert _normalize_profile(None) is None
|
||||
assert _normalize_profile("") is None
|
||||
assert _normalize_profile(" ") is None
|
||||
|
||||
def test_default_profile_is_valid_and_normalized(self, isolated_cron_profile_home):
|
||||
from cron.jobs import _normalize_profile
|
||||
|
||||
assert _normalize_profile("Default") == "default"
|
||||
|
||||
def test_named_profile_must_exist_and_is_normalized(self, isolated_cron_profile_home):
|
||||
from cron.jobs import _normalize_profile
|
||||
|
||||
assert _normalize_profile("Support") == "support"
|
||||
|
||||
def test_invalid_profile_name_is_rejected(self, isolated_cron_profile_home):
|
||||
from cron.jobs import _normalize_profile
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_profile("invalid!")
|
||||
|
||||
def test_missing_named_profile_is_rejected(self, isolated_cron_profile_home):
|
||||
from cron.jobs import _normalize_profile
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
_normalize_profile("missing")
|
||||
|
||||
|
||||
class TestCreateAndUpdateJobProfile:
|
||||
def test_create_stores_profile_id(self, isolated_cron_profile_home):
|
||||
from cron.jobs import create_job, get_job
|
||||
|
||||
job = create_job(prompt="hello", schedule="every 1h", profile="Support")
|
||||
stored = get_job(job["id"])
|
||||
|
||||
assert stored is not None
|
||||
assert stored["profile"] == "support"
|
||||
|
||||
def test_create_without_profile_preserves_old_behaviour(self, isolated_cron_profile_home):
|
||||
from cron.jobs import create_job, get_job
|
||||
|
||||
job = create_job(prompt="hello", schedule="every 1h")
|
||||
stored = get_job(job["id"])
|
||||
|
||||
assert stored is not None
|
||||
assert stored.get("profile") is None
|
||||
|
||||
def test_create_accepts_explicit_default(self, isolated_cron_profile_home):
|
||||
from cron.jobs import create_job, get_job
|
||||
|
||||
job = create_job(prompt="hello", schedule="every 1h", profile="default")
|
||||
stored = get_job(job["id"])
|
||||
|
||||
assert stored is not None
|
||||
assert stored["profile"] == "default"
|
||||
|
||||
def test_update_sets_and_clears_profile(self, isolated_cron_profile_home):
|
||||
from cron.jobs import create_job, get_job, update_job
|
||||
|
||||
job = create_job(prompt="x", schedule="every 1h")
|
||||
update_job(job["id"], {"profile": "Support"})
|
||||
stored = get_job(job["id"])
|
||||
assert stored is not None
|
||||
assert stored["profile"] == "support"
|
||||
|
||||
update_job(job["id"], {"profile": ""})
|
||||
stored = get_job(job["id"])
|
||||
assert stored is not None
|
||||
assert stored["profile"] is None
|
||||
|
||||
def test_update_rejects_missing_profile(self, isolated_cron_profile_home):
|
||||
from cron.jobs import create_job, update_job
|
||||
|
||||
job = create_job(prompt="x", schedule="every 1h")
|
||||
with pytest.raises(FileNotFoundError):
|
||||
update_job(job["id"], {"profile": "missing"})
|
||||
|
||||
|
||||
class TestCronjobToolProfile:
|
||||
def test_create_and_list_with_profile(self, isolated_cron_profile_home):
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
created = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
prompt="hi",
|
||||
schedule="every 1h",
|
||||
profile="Support",
|
||||
)
|
||||
)
|
||||
assert created["success"] is True
|
||||
assert created["job"]["profile"] == "support"
|
||||
|
||||
listing = json.loads(cronjob(action="list"))
|
||||
assert listing["jobs"][0]["profile"] == "support"
|
||||
|
||||
def test_update_clears_profile_with_empty_string(self, isolated_cron_profile_home):
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
created = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
prompt="hi",
|
||||
schedule="every 1h",
|
||||
profile="Support",
|
||||
)
|
||||
)
|
||||
updated = json.loads(
|
||||
cronjob(action="update", job_id=created["job_id"], profile="")
|
||||
)
|
||||
|
||||
assert updated["success"] is True
|
||||
assert "profile" not in updated["job"]
|
||||
|
||||
def test_schema_advertises_profile(self):
|
||||
from tools.cronjob_tools import CRONJOB_SCHEMA
|
||||
|
||||
assert "profile" in CRONJOB_SCHEMA["parameters"]["properties"]
|
||||
desc = CRONJOB_SCHEMA["parameters"]["properties"]["profile"]["description"]
|
||||
desc_lower = desc.lower()
|
||||
assert "hermes profile" in desc_lower
|
||||
assert "context-local" in desc_lower
|
||||
assert "subprocess" in desc_lower
|
||||
assert "temporarily sets hermes_home" not in desc_lower
|
||||
|
||||
|
||||
class TestRunJobProfileContext:
|
||||
@staticmethod
|
||||
def _install_agent_stubs(monkeypatch, observed: dict):
|
||||
import sys
|
||||
import cron.scheduler as sched
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, **kwargs):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
observed["env_home_during_init"] = os.environ.get("HERMES_HOME")
|
||||
observed["profile_env_only_during_init"] = os.environ.get(
|
||||
"HERMES_PROFILE_TEST_ONLY"
|
||||
)
|
||||
observed["profile_env_shared_during_init"] = os.environ.get(
|
||||
"HERMES_PROFILE_TEST_SHARED"
|
||||
)
|
||||
observed["hermes_home_during_init"] = str(get_hermes_home())
|
||||
observed["scheduler_home_during_init"] = str(sched._get_hermes_home())
|
||||
observed["skip_context_files"] = kwargs.get("skip_context_files")
|
||||
|
||||
def run_conversation(self, *_a, **_kw):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
observed["env_home_during_run"] = os.environ.get("HERMES_HOME")
|
||||
observed["profile_env_only_during_run"] = os.environ.get(
|
||||
"HERMES_PROFILE_TEST_ONLY"
|
||||
)
|
||||
observed["profile_env_shared_during_run"] = os.environ.get(
|
||||
"HERMES_PROFILE_TEST_SHARED"
|
||||
)
|
||||
observed["hermes_home_during_run"] = str(get_hermes_home())
|
||||
observed["scheduler_home_during_run"] = str(sched._get_hermes_home())
|
||||
return {"final_response": "done", "messages": []}
|
||||
|
||||
def get_activity_summary(self):
|
||||
return {"seconds_since_activity": 0.0}
|
||||
|
||||
def close(self):
|
||||
observed["closed"] = True
|
||||
|
||||
fake_mod = type(sys)("run_agent")
|
||||
fake_mod.AIAgent = FakeAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_mod)
|
||||
|
||||
from hermes_cli import runtime_provider as runtime_provider
|
||||
|
||||
monkeypatch.setattr(
|
||||
runtime_provider,
|
||||
"resolve_runtime_provider",
|
||||
lambda **_kw: {
|
||||
"provider": "test",
|
||||
"api_key": "test-key",
|
||||
"base_url": "http://test.local",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sched, "_build_job_prompt", lambda job, prerun_script=None: "hi")
|
||||
monkeypatch.setattr(sched, "_resolve_origin", lambda job: None)
|
||||
monkeypatch.setattr(sched, "_resolve_delivery_target", lambda job: None)
|
||||
monkeypatch.setattr(sched, "_resolve_cron_enabled_toolsets", lambda job, cfg: None)
|
||||
monkeypatch.setattr(sched, "_hermes_home", None)
|
||||
monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0")
|
||||
|
||||
import dotenv
|
||||
|
||||
def fake_load_dotenv(path, *_a, **_kw):
|
||||
observed.setdefault("dotenv_paths", []).append(str(path))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv)
|
||||
|
||||
def test_run_job_sets_and_restores_profile_home(
|
||||
self, isolated_cron_profile_home, monkeypatch
|
||||
):
|
||||
import cron.scheduler as sched
|
||||
|
||||
root, profile_home = isolated_cron_profile_home
|
||||
observed: dict = {}
|
||||
self._install_agent_stubs(monkeypatch, observed)
|
||||
|
||||
job = {
|
||||
"id": "abc",
|
||||
"name": "profile-job",
|
||||
"profile": "support",
|
||||
"schedule_display": "manual",
|
||||
}
|
||||
|
||||
success, _output, response, error = sched.run_job(job)
|
||||
|
||||
assert success is True, f"run_job failed: error={error!r} response={response!r}"
|
||||
assert observed["dotenv_paths"] == [str(profile_home / ".env")]
|
||||
assert observed["env_home_during_init"] == str(root)
|
||||
assert observed["env_home_during_run"] == str(root)
|
||||
assert observed["hermes_home_during_init"] == str(profile_home.resolve())
|
||||
assert observed["hermes_home_during_run"] == str(profile_home.resolve())
|
||||
assert observed["scheduler_home_during_init"] == str(profile_home.resolve())
|
||||
assert observed["scheduler_home_during_run"] == str(profile_home.resolve())
|
||||
assert observed["skip_context_files"] is True
|
||||
assert os.environ["HERMES_HOME"] == str(root)
|
||||
assert sched._get_hermes_home() == root
|
||||
|
||||
def test_profile_dotenv_environment_is_restored(
|
||||
self, isolated_cron_profile_home, monkeypatch
|
||||
):
|
||||
import dotenv
|
||||
import cron.scheduler as sched
|
||||
|
||||
root, profile_home = isolated_cron_profile_home
|
||||
observed: dict = {}
|
||||
self._install_agent_stubs(monkeypatch, observed)
|
||||
monkeypatch.setenv("HERMES_PROFILE_TEST_SHARED", "outer")
|
||||
monkeypatch.delenv("HERMES_PROFILE_TEST_ONLY", raising=False)
|
||||
|
||||
def fake_load_dotenv(path, *_a, **_kw):
|
||||
observed.setdefault("dotenv_paths", []).append(str(path))
|
||||
os.environ["HERMES_PROFILE_TEST_SHARED"] = "profile-value"
|
||||
os.environ["HERMES_PROFILE_TEST_ONLY"] = "profile-only"
|
||||
os.environ["HERMES_CRON_TIMEOUT"] = "123"
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv)
|
||||
|
||||
job = {
|
||||
"id": "env-profile",
|
||||
"name": "profile-env-job",
|
||||
"profile": "support",
|
||||
"schedule_display": "manual",
|
||||
}
|
||||
|
||||
success, _output, _response, error = sched.run_job(job)
|
||||
|
||||
assert success is True, error
|
||||
assert observed["dotenv_paths"] == [str(profile_home / ".env")]
|
||||
assert observed["profile_env_only_during_init"] == "profile-only"
|
||||
assert observed["profile_env_shared_during_init"] == "profile-value"
|
||||
assert observed["profile_env_only_during_run"] == "profile-only"
|
||||
assert observed["profile_env_shared_during_run"] == "profile-value"
|
||||
assert os.environ["HERMES_PROFILE_TEST_SHARED"] == "outer"
|
||||
assert "HERMES_PROFILE_TEST_ONLY" not in os.environ
|
||||
assert os.environ["HERMES_CRON_TIMEOUT"] == "0"
|
||||
assert os.environ["HERMES_HOME"] == str(root)
|
||||
assert sched._get_hermes_home() == root
|
||||
|
||||
def test_no_agent_profile_uses_profile_scripts_dir_and_restores_env(
|
||||
self, isolated_cron_profile_home, monkeypatch
|
||||
):
|
||||
import cron.scheduler as sched
|
||||
|
||||
root, profile_home = isolated_cron_profile_home
|
||||
scripts_dir = profile_home / "scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
(scripts_dir / "print_home.py").write_text(
|
||||
"import os\nprint(os.environ.get('HERMES_HOME', ''))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(sched, "_hermes_home", None)
|
||||
|
||||
job = {
|
||||
"id": "script1",
|
||||
"name": "profile-script",
|
||||
"profile": "support",
|
||||
"script": "print_home.py",
|
||||
"no_agent": True,
|
||||
}
|
||||
|
||||
success, _doc, response, error = sched.run_job(job)
|
||||
|
||||
assert success is True, error
|
||||
assert response.strip() == str(profile_home.resolve())
|
||||
assert os.environ["HERMES_HOME"] == str(root)
|
||||
assert sched._get_hermes_home() == root
|
||||
|
||||
def test_run_job_without_profile_leaves_hermes_home_untouched(
|
||||
self, isolated_cron_profile_home, monkeypatch
|
||||
):
|
||||
import cron.scheduler as sched
|
||||
|
||||
root, _profile_home = isolated_cron_profile_home
|
||||
observed: dict = {}
|
||||
self._install_agent_stubs(monkeypatch, observed)
|
||||
|
||||
job = {
|
||||
"id": "noprof",
|
||||
"name": "no-profile-job",
|
||||
"profile": None,
|
||||
"schedule_display": "manual",
|
||||
}
|
||||
|
||||
success, *_ = sched.run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert observed["hermes_home_during_init"] == str(root)
|
||||
assert os.environ["HERMES_HOME"] == str(root)
|
||||
|
||||
def test_run_job_falls_back_on_missing_runtime_profile(
|
||||
self, isolated_cron_profile_home, monkeypatch
|
||||
):
|
||||
import cron.scheduler as sched
|
||||
|
||||
root, _profile_home = isolated_cron_profile_home
|
||||
observed: dict = {}
|
||||
self._install_agent_stubs(monkeypatch, observed)
|
||||
|
||||
job = {
|
||||
"id": "missing-profile",
|
||||
"name": "missing-profile-job",
|
||||
"profile": "missing",
|
||||
"schedule_display": "manual",
|
||||
}
|
||||
|
||||
# Should succeed with fallback, not raise
|
||||
success, _output, response, error = sched.run_job(job)
|
||||
|
||||
assert success is True, f"run_job should fallback, not fail: error={error!r}"
|
||||
# Verify it used the default home, not the missing profile
|
||||
assert observed["hermes_home_during_init"] == str(root)
|
||||
assert os.environ["HERMES_HOME"] == str(root)
|
||||
|
||||
|
||||
class TestTickProfilePartition:
|
||||
def test_profile_and_workdir_combined(self, isolated_cron_profile_home, monkeypatch):
|
||||
"""Both profile and workdir set — verify both are applied and restored."""
|
||||
import cron.scheduler as sched
|
||||
|
||||
root, profile_home = isolated_cron_profile_home
|
||||
observed: dict = {}
|
||||
TestRunJobProfileContext._install_agent_stubs(monkeypatch, observed)
|
||||
fake_workdir = str(root / "myproject")
|
||||
(root / "myproject").mkdir()
|
||||
|
||||
job = {
|
||||
"id": "combo",
|
||||
"name": "combo-job",
|
||||
"profile": "support",
|
||||
"workdir": fake_workdir,
|
||||
"schedule_display": "manual",
|
||||
}
|
||||
|
||||
success, _output, _response, error = sched.run_job(job)
|
||||
|
||||
assert success is True, error
|
||||
assert observed["hermes_home_during_init"] == str(profile_home.resolve())
|
||||
assert os.environ.get("TERMINAL_CWD", "") != fake_workdir, \
|
||||
"TERMINAL_CWD should be restored after job"
|
||||
assert os.environ["HERMES_HOME"] == str(root)
|
||||
assert sched._get_hermes_home() == root
|
||||
|
||||
def test_profile_jobs_run_sequentially(self, isolated_cron_profile_home, monkeypatch):
|
||||
import threading
|
||||
import cron.scheduler as sched
|
||||
|
||||
# Two profile jobs (both sequential) + one parallel job.
|
||||
profile_a = {"id": "a", "name": "A", "profile": "default"}
|
||||
profile_b = {"id": "b", "name": "B", "profile": "default"}
|
||||
parallel_job = {"id": "c", "name": "C", "profile": None}
|
||||
|
||||
monkeypatch.setattr(sched, "get_due_jobs", lambda: [profile_a, profile_b, parallel_job])
|
||||
monkeypatch.setattr(sched, "advance_next_run", lambda *_a, **_kw: None)
|
||||
|
||||
calls: list[tuple[str, str]] = []
|
||||
order_lock = threading.Lock()
|
||||
|
||||
def fake_run_job(job):
|
||||
with order_lock:
|
||||
calls.append((job["id"], threading.current_thread().name))
|
||||
return True, "output", "response", None
|
||||
|
||||
monkeypatch.setattr(sched, "run_job", fake_run_job)
|
||||
monkeypatch.setattr(sched, "save_job_output", lambda _jid, _o: None)
|
||||
monkeypatch.setattr(sched, "mark_job_run", lambda *_a, **_kw: None)
|
||||
monkeypatch.setattr(sched, "_deliver_result", lambda *_a, **_kw: None)
|
||||
|
||||
n = sched.tick(verbose=False)
|
||||
|
||||
assert n == 3
|
||||
ids = [job_id for job_id, _thread_name in calls]
|
||||
# Sequential profile jobs preserve submission order relative to each
|
||||
# other (single-thread pool).
|
||||
assert ids.index("a") < ids.index("b")
|
||||
# Sequential (profile) jobs run on the persistent single-thread
|
||||
# cron-seq pool — NOT the main thread — so a long profile job never
|
||||
# blocks the ticker. Parallel jobs run on the cron-parallel pool.
|
||||
for jid in ("a", "b"):
|
||||
seq_thread = next(t for job_id, t in calls if job_id == jid)
|
||||
assert seq_thread != threading.current_thread().name
|
||||
assert seq_thread.startswith("cron-seq"), seq_thread
|
||||
par_thread = next(t for job_id, t in calls if job_id == "c")
|
||||
assert par_thread.startswith("cron-parallel"), par_thread
|
||||
|
|
@ -172,10 +172,10 @@ class TestSyncMode:
|
|||
|
||||
|
||||
class TestSequentialPool:
|
||||
"""Sequential (workdir/profile) jobs use the persistent cron-seq pool.
|
||||
"""Sequential (workdir) jobs use the persistent cron-seq pool.
|
||||
|
||||
Verifies the follow-up fix: env/context-mutating jobs no longer run inline
|
||||
in the ticker thread, so a long workdir/profile job can't starve the
|
||||
Verifies the follow-up fix: env-mutating jobs no longer run inline
|
||||
in the ticker thread, so a long workdir job can't starve the
|
||||
schedule the same way the parallel path used to.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -1487,7 +1487,7 @@ class TestRunJobConfigLogging:
|
|||
}
|
||||
|
||||
# Mock heavy post-yaml work so the test only exercises the warning
|
||||
# path. Without these mocks, _run_job_impl continues into provider
|
||||
# path. Without these mocks, run_job continues into provider
|
||||
# resolution and MCP discovery, both of which can spawn subprocesses
|
||||
# / hit the network and have caused this test to time out on CI
|
||||
# (>30s wall clock) under load. See PR #33661 follow-up.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -28,13 +28,38 @@ def _stub_mautrix():
|
|||
sys.modules.setdefault(sub, types.ModuleType(sub))
|
||||
sys.modules.setdefault("mautrix", stub)
|
||||
m = sys.modules["mautrix.types"]
|
||||
for attr in (
|
||||
"ContentURI", "EventID", "EventType", "PaginationDirection",
|
||||
"PresenceState", "RoomCreatePreset", "RoomID", "SyncToken",
|
||||
"TrustState", "UserID",
|
||||
):
|
||||
if not hasattr(m, attr):
|
||||
setattr(m, attr, str)
|
||||
|
||||
class EventType:
|
||||
ROOM_MESSAGE = "m.room.message"
|
||||
REACTION = "m.reaction"
|
||||
ROOM_ENCRYPTED = "m.room.encrypted"
|
||||
ROOM_NAME = "m.room.name"
|
||||
|
||||
class PaginationDirection:
|
||||
BACKWARD = "b"
|
||||
FORWARD = "f"
|
||||
|
||||
class PresenceState:
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
UNAVAILABLE = "unavailable"
|
||||
|
||||
class RoomCreatePreset:
|
||||
PRIVATE = "private_chat"
|
||||
PUBLIC = "public_chat"
|
||||
TRUSTED_PRIVATE = "trusted_private_chat"
|
||||
|
||||
class TrustState:
|
||||
UNVERIFIED = 0
|
||||
VERIFIED = 1
|
||||
|
||||
for attr in ("ContentURI", "EventID", "RoomID", "SyncToken", "UserID"):
|
||||
setattr(m, attr, str)
|
||||
m.EventType = EventType
|
||||
m.PaginationDirection = PaginationDirection
|
||||
m.PresenceState = PresenceState
|
||||
m.RoomCreatePreset = RoomCreatePreset
|
||||
m.TrustState = TrustState
|
||||
|
||||
|
||||
_stub_mautrix()
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ class TestMatrixExecApprovalReactions:
|
|||
assert result.success is True
|
||||
assert adapter._approval_prompt_by_session["sess-1"] == "$evt1"
|
||||
assert adapter._approval_prompts_by_event["$evt1"].session_key == "sess-1"
|
||||
assert adapter._send_reaction.await_count == 2
|
||||
assert adapter._send_reaction.await_count == 3
|
||||
emojis = [call.args[2] for call in adapter._send_reaction.await_args_list]
|
||||
assert emojis == ["✅", "❎"]
|
||||
assert emojis == ["✅", "♾️", "❌"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_resolves_pending_approval(self, monkeypatch):
|
||||
|
|
|
|||
510
tests/gateway/test_matrix_project_context_isolation.py
Normal file
510
tests/gateway/test_matrix_project_context_isolation.py
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
"""Matrix Project A / Project B context-isolation regressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import (
|
||||
SessionContext,
|
||||
SessionEntry,
|
||||
SessionSource,
|
||||
build_session_context_prompt,
|
||||
build_session_key,
|
||||
)
|
||||
|
||||
PROJECT_A_ROOM_ID = "!projectA:example.org"
|
||||
PROJECT_B_ROOM_ID = "!projectB:example.org"
|
||||
PROJECT_A_NAME = "Project - Project A"
|
||||
PROJECT_B_NAME = "Project - Project B"
|
||||
PROJECT_A_TOPIC = "Architecture and deploy plan for Project A"
|
||||
PROJECT_B_TOPIC = "Migration and branch plan for Project B"
|
||||
PROJECT_A_ALIAS = "#project-a:example.org"
|
||||
PROJECT_B_ALIAS = "#project-b:example.org"
|
||||
SENDER = "@alice:example.org"
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
|
||||
adapter = MatrixAdapter(
|
||||
PlatformConfig(
|
||||
enabled=True,
|
||||
token="test-token",
|
||||
extra={"homeserver": "https://matrix.example.org", "user_id": "@bot:example.org"},
|
||||
)
|
||||
)
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._require_mention = False
|
||||
adapter._auto_thread = False
|
||||
adapter._matrix_session_scope = "room"
|
||||
adapter._text_batch_delay_seconds = 0
|
||||
adapter._background_read_receipt = MagicMock()
|
||||
adapter._get_display_name = AsyncMock(return_value="Alice")
|
||||
adapter._client = _FakeMatrixClient()
|
||||
return adapter
|
||||
|
||||
|
||||
class _FakeMatrixClient:
|
||||
def __init__(self):
|
||||
self.state_store = MagicMock()
|
||||
self.state_store.get_members = AsyncMock(return_value=["@bot:example.org", SENDER])
|
||||
|
||||
async def get_state_event(self, room_id, event_type):
|
||||
rid = str(room_id)
|
||||
state = {
|
||||
PROJECT_A_ROOM_ID: {
|
||||
"m.room.name": {"content": {"name": PROJECT_A_NAME}},
|
||||
"m.room.topic": {"content": {"topic": PROJECT_A_TOPIC}},
|
||||
"m.room.canonical_alias": {"content": {"alias": PROJECT_A_ALIAS}},
|
||||
},
|
||||
PROJECT_B_ROOM_ID: {
|
||||
"m.room.name": {"content": {"name": PROJECT_B_NAME}},
|
||||
"m.room.topic": {"content": {"topic": PROJECT_B_TOPIC}},
|
||||
"m.room.canonical_alias": {"content": {"alias": PROJECT_B_ALIAS}},
|
||||
},
|
||||
}
|
||||
value = state.get(rid, {}).get(str(event_type))
|
||||
if value is None:
|
||||
raise KeyError((rid, event_type))
|
||||
return value
|
||||
|
||||
|
||||
async def _source_for(adapter, room_id: str, event_id: str = "$event"):
|
||||
ctx = await adapter._resolve_message_context(
|
||||
room_id=room_id,
|
||||
sender=SENDER,
|
||||
event_id=event_id,
|
||||
body="What is next?",
|
||||
source_content={"body": "What is next?"},
|
||||
relates_to={},
|
||||
)
|
||||
assert ctx is not None
|
||||
return ctx[-1]
|
||||
|
||||
|
||||
def _matrix_event(room_id: str, event_id: str, body: str = "What is next?"):
|
||||
event = MagicMock()
|
||||
event.room_id = room_id
|
||||
event.sender = SENDER
|
||||
event.event_id = event_id
|
||||
event.timestamp = int(time.time() * 1000)
|
||||
event.server_timestamp = event.timestamp
|
||||
event.content = {"msgtype": "m.text", "body": body}
|
||||
return event
|
||||
|
||||
|
||||
def _context_for(source: SessionSource) -> SessionContext:
|
||||
return SessionContext(
|
||||
source=source,
|
||||
connected_platforms=[Platform.MATRIX],
|
||||
home_channels={},
|
||||
session_key=build_session_key(source),
|
||||
session_id="session-test",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_source_includes_room_name_topic_and_message_id():
|
||||
adapter = _make_adapter()
|
||||
source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$project-b-msg")
|
||||
|
||||
assert source.chat_id == PROJECT_B_ROOM_ID
|
||||
assert source.chat_name == PROJECT_B_NAME
|
||||
assert source.chat_topic == PROJECT_B_TOPIC
|
||||
assert source.guild_id == "example.org"
|
||||
assert source.message_id == "$project-b-msg"
|
||||
assert source.parent_chat_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_a_and_project_b_have_distinct_session_keys():
|
||||
adapter = _make_adapter()
|
||||
source_a = await _source_for(adapter, PROJECT_A_ROOM_ID, "$a")
|
||||
source_b = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b")
|
||||
|
||||
assert source_a.chat_id != source_b.chat_id
|
||||
assert source_a.chat_name == PROJECT_A_NAME
|
||||
assert source_b.chat_name == PROJECT_B_NAME
|
||||
assert build_session_key(source_a) != build_session_key(source_b)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_b_prompt_contains_project_b_not_project_a():
|
||||
adapter = _make_adapter()
|
||||
source_b = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b")
|
||||
|
||||
prompt = build_session_context_prompt(_context_for(source_b))
|
||||
|
||||
assert PROJECT_B_NAME in prompt
|
||||
assert PROJECT_B_TOPIC in prompt
|
||||
assert PROJECT_B_ROOM_ID in prompt
|
||||
assert "Matrix room boundary" in prompt
|
||||
assert PROJECT_A_NAME not in prompt
|
||||
assert PROJECT_A_TOPIC not in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_context_survives_sequential_messages():
|
||||
adapter = _make_adapter()
|
||||
adapter._matrix_session_scope = "room"
|
||||
first = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b1")
|
||||
second = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b2")
|
||||
|
||||
assert first.thread_id is None
|
||||
assert second.thread_id is None
|
||||
assert first.chat_name == PROJECT_B_NAME
|
||||
assert second.chat_name == PROJECT_B_NAME
|
||||
assert build_session_key(first) == build_session_key(second)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_session_scope_auto_and_thread_preserve_synthetic_threads():
|
||||
adapter = _make_adapter()
|
||||
adapter._auto_thread = True
|
||||
adapter._matrix_session_scope = "auto"
|
||||
auto_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$auto")
|
||||
assert auto_source.thread_id == "$auto"
|
||||
|
||||
adapter._matrix_session_scope = "thread"
|
||||
thread_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$thread")
|
||||
assert thread_source.thread_id == "$thread"
|
||||
|
||||
real_thread = await adapter._resolve_message_context(
|
||||
room_id=PROJECT_B_ROOM_ID,
|
||||
sender=SENDER,
|
||||
event_id="$reply",
|
||||
body="thread reply",
|
||||
source_content={"body": "thread reply"},
|
||||
relates_to={"rel_type": "m.thread", "event_id": "$root"},
|
||||
)
|
||||
assert real_thread is not None
|
||||
assert real_thread[-1].thread_id == "$root"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_context_survives_concurrent_messages():
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
async def observe(room_id: str):
|
||||
adapter = _make_adapter()
|
||||
source = await _source_for(adapter, room_id, f"${room_id}")
|
||||
context = _context_for(source)
|
||||
runner = object.__new__(GatewayRunner)
|
||||
tokens = runner._set_session_env(context)
|
||||
try:
|
||||
await asyncio.sleep(0)
|
||||
return SimpleNamespace(
|
||||
chat_id=get_session_env("HERMES_SESSION_CHAT_ID"),
|
||||
chat_name=get_session_env("HERMES_SESSION_CHAT_NAME"),
|
||||
session_key=get_session_env("HERMES_SESSION_KEY"),
|
||||
)
|
||||
finally:
|
||||
runner._clear_session_env(tokens)
|
||||
|
||||
observed_a, observed_b = await asyncio.gather(
|
||||
observe(PROJECT_A_ROOM_ID),
|
||||
observe(PROJECT_B_ROOM_ID),
|
||||
)
|
||||
|
||||
assert observed_a.chat_id == PROJECT_A_ROOM_ID
|
||||
assert observed_b.chat_id == PROJECT_B_ROOM_ID
|
||||
assert observed_a.chat_name == PROJECT_A_NAME
|
||||
assert observed_b.chat_name == PROJECT_B_NAME
|
||||
assert observed_a.session_key != observed_b.session_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_inbound_handler_emits_project_b_metadata_not_project_a():
|
||||
adapter = _make_adapter()
|
||||
captured = []
|
||||
|
||||
async def capture(event):
|
||||
captured.append(event)
|
||||
|
||||
adapter.handle_message = capture
|
||||
|
||||
await adapter._on_room_message(_matrix_event(PROJECT_B_ROOM_ID, "$project-b"))
|
||||
|
||||
assert len(captured) == 1
|
||||
source = captured[0].source
|
||||
assert source.chat_id == PROJECT_B_ROOM_ID
|
||||
assert source.chat_name == PROJECT_B_NAME
|
||||
assert source.chat_topic == PROJECT_B_TOPIC
|
||||
assert source.message_id == "$project-b"
|
||||
assert PROJECT_A_NAME not in repr(source.to_dict())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_inbound_handler_keeps_project_a_and_b_distinct():
|
||||
adapter = _make_adapter()
|
||||
captured = []
|
||||
|
||||
async def capture(event):
|
||||
captured.append(event)
|
||||
|
||||
adapter.handle_message = capture
|
||||
|
||||
await adapter._on_room_message(_matrix_event(PROJECT_A_ROOM_ID, "$project-a", "A"))
|
||||
await adapter._on_room_message(_matrix_event(PROJECT_B_ROOM_ID, "$project-b", "B"))
|
||||
|
||||
assert [event.source.chat_id for event in captured] == [
|
||||
PROJECT_A_ROOM_ID,
|
||||
PROJECT_B_ROOM_ID,
|
||||
]
|
||||
assert [event.source.chat_name for event in captured] == [
|
||||
PROJECT_A_NAME,
|
||||
PROJECT_B_NAME,
|
||||
]
|
||||
assert build_session_key(captured[0].source) != build_session_key(captured[1].source)
|
||||
|
||||
|
||||
def test_matrix_room_scope_group_sessions_per_user_true_separates_users():
|
||||
alice = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob.user_id = "@bob:example.org"
|
||||
alice.thread_id = None
|
||||
bob.thread_id = None
|
||||
|
||||
assert build_session_key(alice, group_sessions_per_user=True) != build_session_key(
|
||||
bob,
|
||||
group_sessions_per_user=True,
|
||||
)
|
||||
|
||||
|
||||
def test_matrix_room_scope_group_sessions_per_user_false_shares_room():
|
||||
alice = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob.user_id = "@bob:example.org"
|
||||
alice.thread_id = None
|
||||
bob.thread_id = None
|
||||
|
||||
assert build_session_key(alice, group_sessions_per_user=False) == build_session_key(
|
||||
bob,
|
||||
group_sessions_per_user=False,
|
||||
)
|
||||
|
||||
|
||||
def _make_matrix_source(room_id: str, room_name: str, topic: str) -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.MATRIX,
|
||||
chat_id=room_id,
|
||||
chat_name=room_name,
|
||||
chat_type="group",
|
||||
user_id=SENDER,
|
||||
user_name="Alice",
|
||||
chat_topic=topic,
|
||||
)
|
||||
|
||||
|
||||
def _entry(source: SessionSource, session_id: str, title: str | None = None) -> SessionEntry:
|
||||
return SessionEntry(
|
||||
session_key=build_session_key(source),
|
||||
session_id=session_id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
origin=source,
|
||||
display_name=title or source.chat_name,
|
||||
platform=Platform.MATRIX,
|
||||
chat_type="group",
|
||||
)
|
||||
|
||||
|
||||
def _make_runner(current_source: SessionSource, entries: list[SessionEntry]):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(platforms={Platform.MATRIX: PlatformConfig(enabled=True)})
|
||||
adapter = MagicMock()
|
||||
adapter._matrix_session_scope = "room"
|
||||
runner.adapters = {Platform.MATRIX: adapter}
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store._entries = {entry.session_key: entry for entry in entries}
|
||||
current = next((e for e in entries if e.origin and e.origin.chat_id == current_source.chat_id), entries[0])
|
||||
runner.session_store.get_or_create_session.return_value = current
|
||||
runner.session_store.switch_session.return_value = current
|
||||
runner.session_store.load_transcript.return_value = [{"role": "user", "content": "hello"}]
|
||||
runner._running_agents = {}
|
||||
runner._session_run_generation = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._release_running_agent_state = MagicMock()
|
||||
runner._clear_session_boundary_security_state = MagicMock()
|
||||
runner._evict_cached_agent = MagicMock()
|
||||
runner._queue_depth = MagicMock(return_value=0)
|
||||
runner._session_db = MagicMock()
|
||||
runner._session_db.list_sessions_rich.return_value = [
|
||||
{"id": entry.session_id, "title": entry.display_name, "preview": ""}
|
||||
for entry in entries
|
||||
]
|
||||
runner._session_db.resolve_resume_session_id.side_effect = lambda sid: sid
|
||||
runner._session_db.get_session_title.side_effect = lambda sid: {
|
||||
entry.session_id: entry.display_name for entry in entries
|
||||
}.get(sid)
|
||||
runner._session_db.get_session.return_value = None
|
||||
return runner
|
||||
|
||||
|
||||
def _event(text: str, source: SessionSource) -> MessageEvent:
|
||||
return MessageEvent(text=text, source=source, message_id="$cmd")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_status_reports_current_matrix_room_scope():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [_entry(source_a, "session-a", "Project A Plan"), entry_b])
|
||||
|
||||
result = await runner._handle_status_command(_event("/status", source_b))
|
||||
|
||||
assert "Matrix scope:" in result
|
||||
assert PROJECT_B_NAME in result
|
||||
assert PROJECT_B_ROOM_ID in result
|
||||
assert "session_scope: room" in result
|
||||
session_key = build_session_key(source_b)
|
||||
assert session_key not in result
|
||||
assert session_key[:8] not in result
|
||||
assert "session_key: sha256:" in result
|
||||
assert PROJECT_A_NAME not in result
|
||||
assert PROJECT_A_ROOM_ID not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_does_not_cross_rooms_by_default():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_a = _entry(source_a, "session-a", "Project A Plan")
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_a, entry_b])
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-a"
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume Project A Plan", source_b))
|
||||
|
||||
assert "blocked" in result
|
||||
assert PROJECT_A_NAME in result
|
||||
runner.session_store.switch_session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_allows_same_room_session():
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_b = _entry(source_b, "session-b-old", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_b])
|
||||
runner.session_store.get_or_create_session.return_value = _entry(
|
||||
source_b, "session-b-current", "Current Project B"
|
||||
)
|
||||
runner.session_store.switch_session.return_value = entry_b
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-b-old"
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume Project B Plan", source_b))
|
||||
|
||||
assert "Resumed session" in result
|
||||
runner.session_store.switch_session.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_quoted_title_same_room():
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_b = _entry(source_b, "session-b-old", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_b])
|
||||
runner.session_store.get_or_create_session.return_value = _entry(
|
||||
source_b, "session-b-current", "Current Project B"
|
||||
)
|
||||
runner.session_store.switch_session.return_value = entry_b
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-b-old"
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event('/resume "Project B Plan"', source_b)
|
||||
)
|
||||
|
||||
assert "Resumed session" in result
|
||||
runner._session_db.resolve_session_by_title.assert_called_once_with("Project B Plan")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_quoted_title_cross_room_blocked():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_a = _entry(source_a, "session-a", "Project A Plan")
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_a, entry_b])
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-a"
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event('/resume "Project A Plan"', source_b)
|
||||
)
|
||||
|
||||
assert "blocked" in result
|
||||
runner.session_store.switch_session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_malformed_quote_returns_helpful_error():
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
runner = _make_runner(source_b, [_entry(source_b, "session-b", "Project B Plan")])
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event('/resume "Project B Plan', source_b)
|
||||
)
|
||||
|
||||
assert "Could not parse" in result
|
||||
assert "quotes" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_cross_room_requires_explicit_flag_and_warns():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_a = _entry(source_a, "session-a", "Project A Plan")
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_a, entry_b])
|
||||
runner.session_store.switch_session.return_value = entry_a
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-a"
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event("/resume --cross-room Project A Plan", source_b)
|
||||
)
|
||||
|
||||
assert "Cross-room resume" in result
|
||||
assert PROJECT_B_NAME in result
|
||||
runner.session_store.switch_session.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_lists_only_current_room_by_default():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
runner = _make_runner(
|
||||
source_b,
|
||||
[_entry(source_a, "session-a", "Project A Plan"), _entry(source_b, "session-b", "Project B Plan")],
|
||||
)
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume", source_b))
|
||||
|
||||
assert "Project B Plan" in result
|
||||
assert "Project A Plan" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_all_lists_room_names():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
runner = _make_runner(
|
||||
source_b,
|
||||
[_entry(source_a, "session-a", "Project A Plan"), _entry(source_b, "session-b", "Project B Plan")],
|
||||
)
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume --all", source_b))
|
||||
|
||||
assert "Project A Plan" in result
|
||||
assert PROJECT_A_NAME in result
|
||||
assert "Project B Plan" in result
|
||||
|
|
@ -197,8 +197,10 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
|
|||
runner, _adapter = make_restart_runner()
|
||||
popen_calls = []
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "linux")
|
||||
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["/usr/bin/hermes"])
|
||||
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
|
||||
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
||||
monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/setsid" if cmd == "setsid" else None)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
|
|
@ -217,6 +219,72 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
|
|||
assert kwargs["start_new_session"] is True
|
||||
assert kwargs["stdout"] is subprocess.DEVNULL
|
||||
assert kwargs["stderr"] is subprocess.DEVNULL
|
||||
# The watcher must NOT inherit the gateway marker, or the CLI's
|
||||
# self-restart loop guard refuses to run `hermes gateway restart`.
|
||||
assert kwargs["env"].get("_HERMES_GATEWAY") is None
|
||||
|
||||
|
||||
def test_windows_gateway_venv_imports_add_site_packages(monkeypatch, tmp_path):
|
||||
venv_dir = tmp_path / "venv"
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
pth_extra = tmp_path / "pywin32_system32"
|
||||
site_packages.mkdir(parents=True)
|
||||
pth_extra.mkdir()
|
||||
(site_packages / "pywin32.pth").write_text(str(pth_extra), encoding="utf-8")
|
||||
project_root = str(gateway_run.Path(gateway_run.__file__).resolve().parent.parent)
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "win32")
|
||||
monkeypatch.setattr(gateway_run.sys, "path", ["existing"])
|
||||
monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
|
||||
monkeypatch.setenv("PYTHONPATH", "already-there")
|
||||
|
||||
gateway_run._ensure_windows_gateway_venv_imports()
|
||||
|
||||
assert gateway_run.sys.path[:2] == [project_root, str(site_packages)]
|
||||
assert str(pth_extra) in gateway_run.sys.path
|
||||
assert gateway_run.os.environ["VIRTUAL_ENV"] == str(venv_dir.resolve())
|
||||
pythonpath = gateway_run.os.environ["PYTHONPATH"].split(gateway_run.os.pathsep)
|
||||
assert pythonpath[:3] == [project_root, str(site_packages), "already-there"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_detached_restart_scrubs_gateway_marker(monkeypatch, tmp_path):
|
||||
runner, _adapter = make_restart_runner()
|
||||
popen_calls = []
|
||||
venv_dir = tmp_path / "venv"
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
site_packages.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "win32")
|
||||
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"])
|
||||
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
|
||||
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
||||
monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
|
||||
|
||||
import hermes_cli._subprocess_compat as subprocess_compat
|
||||
|
||||
monkeypatch.setattr(
|
||||
subprocess_compat,
|
||||
"windows_detach_popen_kwargs",
|
||||
lambda: {},
|
||||
)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
popen_calls.append((cmd, kwargs))
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
|
||||
await runner._launch_detached_restart_command()
|
||||
|
||||
assert len(popen_calls) == 1
|
||||
cmd, kwargs = popen_calls[0]
|
||||
assert cmd[-3:] == ["hermes", "gateway", "restart"]
|
||||
assert kwargs["env"].get("_HERMES_GATEWAY") is None
|
||||
assert kwargs["env"]["VIRTUAL_ENV"] == str(venv_dir)
|
||||
assert str(site_packages) in kwargs["env"]["PYTHONPATH"].split(gateway_run.os.pathsep)
|
||||
assert kwargs["stdout"] is subprocess.DEVNULL
|
||||
assert kwargs["stderr"] is subprocess.DEVNULL
|
||||
|
||||
|
||||
# ── Shutdown notification tests ──────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1488,3 +1488,72 @@ async def test_terminal_progress_no_bash_block_in_verbose_mode(monkeypatch, tmp_
|
|||
all_content = " ".join(call["content"] for call in adapter.sent)
|
||||
all_content += " ".join(call["content"] for call in adapter.edits)
|
||||
assert "```bash" not in all_content
|
||||
|
||||
class MultiTerminalCommandAgent:
|
||||
"""Emits several consecutive terminal tool.started events, then a
|
||||
different tool, then terminal again — to exercise header collapsing."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, message, conversation_history=None, task_id=None):
|
||||
cb = self.tool_progress_callback
|
||||
cb("tool.started", "terminal", "echo one", {"command": "echo one"})
|
||||
cb("tool.started", "terminal", "echo two", {"command": "echo two"})
|
||||
cb("tool.started", "terminal", "echo three", {"command": "echo three"})
|
||||
cb("tool.started", "web_search", "query stuff", {"query": "query stuff"})
|
||||
cb("tool.started", "terminal", "echo four", {"command": "echo four"})
|
||||
time.sleep(0.35)
|
||||
return {"final_response": "done", "messages": [], "api_calls": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consecutive_terminal_progress_collapses_headers(monkeypatch, tmp_path):
|
||||
"""Back-to-back terminal calls render ONE "terminal" header followed by
|
||||
adjacent code blocks; a different tool in between resets the header so the
|
||||
next terminal call gets a fresh one."""
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = MultiTerminalCommandAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
||||
|
||||
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
||||
runner = _make_runner(adapter)
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
result = await runner._run_agent(
|
||||
message="hello",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="sess-terminal-consecutive",
|
||||
session_key="agent:main:telegram:dm:12345",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
contents = [call["content"] for call in adapter.sent] + [
|
||||
call["content"] for call in adapter.edits
|
||||
]
|
||||
final = max(contents, key=len) if contents else ""
|
||||
# All four commands present as code blocks.
|
||||
for cmd in ("echo one", "echo two", "echo three", "echo four"):
|
||||
assert cmd in final
|
||||
# Exactly TWO terminal headers: one for the first run of three calls,
|
||||
# one for the terminal call after web_search broke the streak.
|
||||
assert final.count("terminal\n```") == 2
|
||||
|
|
|
|||
|
|
@ -611,6 +611,30 @@ class TestSessionStoreSwitchSession:
|
|||
db.close()
|
||||
|
||||
|
||||
class TestSessionStoreLookupBySessionId:
|
||||
@pytest.fixture()
|
||||
def store(self, tmp_path):
|
||||
config = GatewayConfig()
|
||||
with patch("gateway.session.SessionStore._ensure_loaded"):
|
||||
s = SessionStore(sessions_dir=tmp_path, config=config)
|
||||
s._db = None
|
||||
s._loaded = True
|
||||
return s
|
||||
|
||||
def test_returns_active_entry_for_persisted_session_id(self, store):
|
||||
source = SessionSource(
|
||||
platform=Platform.MATRIX,
|
||||
chat_id="!room:example.org",
|
||||
chat_type="group",
|
||||
user_id="@alice:example.org",
|
||||
)
|
||||
entry = store.get_or_create_session(source)
|
||||
|
||||
assert store.lookup_by_session_id(entry.session_id) is entry
|
||||
assert store.lookup_by_session_id("missing") is None
|
||||
assert store.lookup_by_session_id("") is None
|
||||
|
||||
|
||||
class TestWhatsAppSessionKeyConsistency:
|
||||
"""Regression: WhatsApp session keys must collapse JID/LID aliases to a
|
||||
single stable identity for both DM chat_ids and group participant_ids."""
|
||||
|
|
|
|||
|
|
@ -794,9 +794,11 @@ class TestSegmentBreakOnToolBoundary:
|
|||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_deletes_partial_after_chunks_succeed(self):
|
||||
"""After fallback chunks land, the frozen partial must be deleted so
|
||||
the user sees only the complete response (#16668)."""
|
||||
async def test_fallback_final_deletes_partial_after_full_resend(self):
|
||||
"""After fallback re-sends the COMPLETE response, the frozen partial
|
||||
must be deleted so the user sees only the complete response (#16668).
|
||||
Full resend happens when the visible prefix doesn't match the final
|
||||
text (e.g. post-segment-break content, #10807)."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_new"),
|
||||
|
|
@ -810,14 +812,49 @@ class TestSegmentBreakOnToolBoundary:
|
|||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# Seed the consumer as if it already edited a partial message that
|
||||
# later got stuck (flood control etc.) — _message_id is the stale id.
|
||||
# The stale partial shows pre-tool text that is NOT a prefix of the
|
||||
# final response — fallback re-sends the complete final text.
|
||||
consumer._message_id = "msg_partial"
|
||||
consumer._last_sent_text = "Let me check that for you…"
|
||||
|
||||
await consumer._send_fallback_final("Working on it. Done!")
|
||||
|
||||
adapter.delete_message.assert_awaited_once_with("chat_123", "msg_partial")
|
||||
assert consumer._final_response_sent is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_keeps_partial_after_tail_only_send(self):
|
||||
"""When the fallback sends only the missing TAIL (visible prefix
|
||||
matches the final text), the partial message IS the head of the
|
||||
answer — deleting it would leave the user with only the last part
|
||||
of the response (the 'model sent only the second half' bug)."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_new"),
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True),
|
||||
)
|
||||
adapter.delete_message = AsyncMock(return_value=None)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# Visible partial is a true prefix of the final response — the
|
||||
# fallback dedup sends only the tail.
|
||||
consumer._message_id = "msg_partial"
|
||||
consumer._last_sent_text = "Working on i"
|
||||
|
||||
await consumer._send_fallback_final("Working on it. Done!")
|
||||
|
||||
adapter.delete_message.assert_awaited_once_with("chat_123", "msg_partial")
|
||||
# Tail was sent...
|
||||
sent_contents = [
|
||||
c.kwargs.get("content", "") for c in adapter.send.call_args_list
|
||||
]
|
||||
assert any("Done!" in s and "Working on i" not in s for s in sent_contents)
|
||||
# ...and the head-bearing partial was NOT deleted.
|
||||
adapter.delete_message.assert_not_awaited()
|
||||
assert consumer._final_response_sent is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -347,6 +347,200 @@ class TestSegmentBreakDoesNotMarkFinalSent:
|
|||
assert any("answer is 42" in t for t in self._delivered_texts(adapter))
|
||||
|
||||
|
||||
class TestCancelledBestEffortDeliveryFinalizes:
|
||||
"""Cancel-path best-effort delivery must go through the finalize path.
|
||||
|
||||
The gateway cancels the consumer shortly after finish(). The
|
||||
CancelledError handler re-delivers the accumulated text; previously it
|
||||
did so with finalize=False, so REQUIRES_EDIT_FINALIZE platforms
|
||||
(Telegram) kept the plain streaming preview — the whole final reply
|
||||
rendered with raw markdown markers — while the success flags still
|
||||
suppressed the gateway's formatted re-send.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_best_effort_edit_is_finalized(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Reply with **bold** and `code` markers.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05) # preview lands; message_id set
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
finalize_edits = [
|
||||
c for c in adapter.edit_message.call_args_list
|
||||
if c.kwargs.get("finalize")
|
||||
]
|
||||
assert finalize_edits, (
|
||||
"cancel best-effort delivery must use finalize=True so "
|
||||
"REQUIRES_EDIT_FINALIZE platforms apply final formatting"
|
||||
)
|
||||
assert consumer.final_response_sent is True
|
||||
assert consumer.final_content_delivered is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_best_effort_failure_keeps_gateway_resend_possible(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Reply with **bold** and `code` markers.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
# Best-effort delivery at cancel time fails.
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(
|
||||
success=False, error="boom",
|
||||
))
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert consumer.final_response_sent is False
|
||||
assert consumer.final_content_delivered is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_without_preview_makes_no_delivery_attempt(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
),
|
||||
)
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.02)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
adapter.edit_message.assert_not_called()
|
||||
assert consumer.final_response_sent is False
|
||||
assert consumer.final_content_delivered is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_with_fresh_final_enabled_delivers_and_flags_via_handler(self):
|
||||
"""With fresh_final_after_seconds enabled and an aged preview, the
|
||||
finalized cancel-path delivery is eligible for fresh-final
|
||||
(delete + fresh send). is_turn_final=False keeps _try_fresh_final
|
||||
from setting the flags itself; the cancel handler sets them after
|
||||
the successful delivery."""
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
adapter.send.side_effect = [
|
||||
SimpleNamespace(success=True, message_id="initial_preview"),
|
||||
SimpleNamespace(success=True, message_id="fresh_final"),
|
||||
]
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
fresh_final_after_seconds=0.001,
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Reply with **bold** and `code` markers.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer._message_created_ts = 0.0 # force the preview stale
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
# Fresh-final engaged: a second send replaced the stale preview.
|
||||
assert adapter.send.call_count == 2
|
||||
adapter.delete_message.assert_awaited_once_with("chat", "initial_preview")
|
||||
# Flags were set by the cancel handler after successful delivery.
|
||||
assert consumer.final_response_sent is True
|
||||
assert consumer.final_content_delivered is True
|
||||
|
||||
|
||||
class TestGotDoneOverflowSplitNotRefinalized:
|
||||
"""A got_done finalize edit that split-and-delivered across continuation
|
||||
messages must not be followed by the redundant requires-finalize edit.
|
||||
|
||||
After a split, the consumer adopts the last continuation as the live
|
||||
message and the redundant finalize edit re-submits the FULL accumulated
|
||||
text against it; the adapter pre-flights that into another overflow
|
||||
split, editing chunk 1 over the continuation and re-sending the rest,
|
||||
so the user sees duplicated chunks. The finalize signal was already
|
||||
carried by the split edit itself.
|
||||
"""
|
||||
|
||||
def _consumer(self, adapter):
|
||||
# High interval/threshold so the only edit is the got_done finalize.
|
||||
return GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=10.0, buffer_threshold=10_000, cursor=" ▉",
|
||||
),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_finalize_edit_is_not_refinalized(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(
|
||||
success=True,
|
||||
message_id="cont_2",
|
||||
continuation_message_ids=("cont_2",),
|
||||
))
|
||||
consumer = self._consumer(adapter)
|
||||
consumer.on_delta("oversize **markdown** final reply")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05) # preview send lands; no interval edits
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
finalize_edits = [
|
||||
c for c in adapter.edit_message.call_args_list
|
||||
if c.kwargs.get("finalize")
|
||||
]
|
||||
assert len(finalize_edits) == 1, (
|
||||
"split finalize edit must not be re-finalized; the redundant "
|
||||
"edit re-splits the full text into the adopted continuation "
|
||||
"and duplicates chunks on screen"
|
||||
)
|
||||
assert consumer.final_response_sent is True
|
||||
assert consumer.final_content_delivered is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_split_finalize_edit_still_gets_explicit_refinalize(self):
|
||||
"""The narrow fix must not regress the requires-finalize contract:
|
||||
a normal (non-split) got_done edit is still followed by the
|
||||
explicit finalize edit (#25010 semantics unchanged)."""
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(
|
||||
success=True, message_id="initial_preview",
|
||||
))
|
||||
consumer = self._consumer(adapter)
|
||||
consumer.on_delta("short final reply")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
finalize_edits = [
|
||||
c for c in adapter.edit_message.call_args_list
|
||||
if c.kwargs.get("finalize")
|
||||
]
|
||||
assert len(finalize_edits) == 2
|
||||
assert consumer.final_response_sent is True
|
||||
|
||||
|
||||
class TestStreamConsumerConfigFreshFinalField:
|
||||
"""The dataclass field must exist and default to 0 (disabled)."""
|
||||
|
||||
|
|
|
|||
140
tests/gateway/test_telegram_overflow_partial.py
Normal file
140
tests/gateway/test_telegram_overflow_partial.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Regression coverage for partial Telegram overflow delivery."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import SendResult
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from gateway.stream_consumer import GatewayStreamConsumer
|
||||
|
||||
|
||||
def _message(message_id: int | str) -> SimpleNamespace:
|
||||
return SimpleNamespace(message_id=message_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def telegram_adapter() -> TelegramAdapter:
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
|
||||
adapter._bot = MagicMock()
|
||||
object.__setattr__(adapter, "MAX_MESSAGE_LENGTH", 160)
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_overflow_split_reports_success_when_all_continuations_land(telegram_adapter):
|
||||
"""Complete overflow delivery keeps the existing successful contract."""
|
||||
content = "word " * 120
|
||||
telegram_adapter._bot.edit_message_text = AsyncMock(return_value=True)
|
||||
telegram_adapter._bot.send_message = AsyncMock(
|
||||
side_effect=[_message(202), _message(203), _message(204), _message(205)]
|
||||
)
|
||||
|
||||
result = await telegram_adapter._edit_overflow_split(
|
||||
"12345", "201", content, finalize=False, metadata={"thread_id": "77"}
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == result.continuation_message_ids[-1]
|
||||
assert result.raw_response is None
|
||||
assert telegram_adapter._bot.edit_message_text.await_count == 1
|
||||
assert telegram_adapter._bot.send_message.await_count == len(result.continuation_message_ids)
|
||||
for call in telegram_adapter._bot.send_message.await_args_list:
|
||||
assert call.kwargs["message_thread_id"] == 77
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_overflow_split_reports_later_partial_failure_after_some_continuations_land(telegram_adapter):
|
||||
"""Partial metadata tracks the last delivered continuation before failure."""
|
||||
content = "word " * 120
|
||||
telegram_adapter._bot.edit_message_text = AsyncMock(return_value=True)
|
||||
telegram_adapter._bot.send_message = AsyncMock(
|
||||
side_effect=[
|
||||
_message(202),
|
||||
RuntimeError("telegram send failed"),
|
||||
RuntimeError("telegram send failed"),
|
||||
]
|
||||
)
|
||||
|
||||
result = await telegram_adapter._edit_overflow_split(
|
||||
"12345", "201", content, finalize=False, metadata={"thread_id": "77"}
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.message_id == "202"
|
||||
assert result.raw_response["partial_overflow"] is True
|
||||
assert result.raw_response["delivered_chunks"] == 2
|
||||
assert result.raw_response["last_message_id"] == "202"
|
||||
assert result.continuation_message_ids == ("202",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_overflow_split_reports_partial_failure_when_continuation_fails(telegram_adapter):
|
||||
"""A failed continuation must not be reported as final delivery."""
|
||||
content = "word " * 120
|
||||
telegram_adapter._bot.edit_message_text = AsyncMock(return_value=True)
|
||||
telegram_adapter._bot.send_message = AsyncMock(
|
||||
side_effect=[RuntimeError("telegram send failed"), RuntimeError("telegram send failed")]
|
||||
)
|
||||
|
||||
result = await telegram_adapter._edit_overflow_split(
|
||||
"12345", "201", content, finalize=False, metadata={"thread_id": "77"}
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.retryable is True
|
||||
assert result.error == "overflow_continuation_failed"
|
||||
assert result.message_id == "201"
|
||||
assert result.raw_response["partial_overflow"] is True
|
||||
assert result.raw_response["delivered_chunks"] == 1
|
||||
assert result.raw_response["total_chunks"] > 1
|
||||
assert result.raw_response["last_message_id"] == "201"
|
||||
assert result.raw_response["delivered_prefix"]
|
||||
assert result.continuation_message_ids == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_consumer_fallback_sends_tail_after_partial_overflow():
|
||||
"""A partial overflow edit enters fallback instead of marking final delivered."""
|
||||
adapter = MagicMock()
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SendResult(
|
||||
success=False,
|
||||
message_id="preview-1",
|
||||
error="overflow_continuation_failed",
|
||||
retryable=True,
|
||||
raw_response={
|
||||
"partial_overflow": True,
|
||||
"delivered_chunks": 1,
|
||||
"total_chunks": 2,
|
||||
"last_message_id": "preview-1",
|
||||
"delivered_prefix": "hello ",
|
||||
},
|
||||
)
|
||||
)
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="tail-1"))
|
||||
adapter.delete_message = AsyncMock(return_value=True)
|
||||
|
||||
consumer = GatewayStreamConsumer(adapter, "chat-1", metadata={"thread_id": "77"})
|
||||
consumer._message_id = "preview-1"
|
||||
consumer._last_sent_text = "hello "
|
||||
|
||||
ok = await consumer._send_or_edit("hello world", finalize=True)
|
||||
|
||||
assert ok is False
|
||||
assert consumer.final_response_sent is False
|
||||
assert consumer.final_content_delivered is False
|
||||
assert consumer._fallback_final_send is True
|
||||
assert consumer._fallback_prefix == "hello "
|
||||
|
||||
await consumer._send_fallback_final("hello world")
|
||||
|
||||
adapter.send.assert_awaited_once()
|
||||
assert adapter.send.await_args.kwargs["content"] == "world"
|
||||
assert adapter.send.await_args.kwargs["metadata"] == {"thread_id": "77"}
|
||||
adapter.delete_message.assert_not_awaited()
|
||||
assert consumer.final_response_sent is True
|
||||
assert consumer.final_content_delivered is True
|
||||
71
tests/gateway/test_telegram_voice_v0_regressions.py
Normal file
71
tests/gateway/test_telegram_voice_v0_regressions.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _source():
|
||||
return SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
|
||||
|
||||
|
||||
def _runner(adapter=None):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = SimpleNamespace(
|
||||
stt_enabled=True,
|
||||
group_sessions_per_user=True,
|
||||
thread_sessions_per_user=False,
|
||||
)
|
||||
runner.adapters = {Platform.TELEGRAM: adapter} if adapter else {}
|
||||
runner._consume_pending_native_image_paths = lambda _key: []
|
||||
runner._session_key_for_source = lambda _source: "telegram:dm:12345"
|
||||
runner._thread_metadata_for_source = lambda *_args, **_kwargs: {}
|
||||
runner._reply_anchor_for_event = lambda _event: None
|
||||
return runner
|
||||
|
||||
|
||||
def test_telegram_audio_size_gate_rejects_oversized_media_before_download():
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter._max_doc_bytes = 1024
|
||||
|
||||
allowed, note = adapter._telegram_media_size_allowed(
|
||||
SimpleNamespace(file_size=2048),
|
||||
"voice message",
|
||||
)
|
||||
|
||||
assert allowed is False
|
||||
assert "exceeds" in note
|
||||
assert "voice message" in note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_tts_is_explicit_audio_reply_opt_in():
|
||||
adapter = SimpleNamespace(
|
||||
_auto_tts_disabled_chats=set(),
|
||||
_auto_tts_enabled_chats=set(),
|
||||
)
|
||||
runner = _runner(adapter)
|
||||
runner._voice_mode = {}
|
||||
runner._voice_provider_mode = {}
|
||||
runner._save_voice_modes = lambda: None
|
||||
runner._save_voice_provider_modes = lambda: None
|
||||
|
||||
event = SimpleNamespace(
|
||||
source=_source(),
|
||||
get_command_args=lambda: "tts",
|
||||
)
|
||||
result = await GatewayRunner._handle_voice_command(runner, event)
|
||||
|
||||
assert runner._voice_mode["telegram:12345"] == "all"
|
||||
assert "12345" in adapter._auto_tts_enabled_chats
|
||||
assert result
|
||||
341
tests/gateway/test_whatsapp_stale_bridge.py
Normal file
341
tests/gateway/test_whatsapp_stale_bridge.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
"""Tests for the WhatsApp stale-bridge staleness handshake.
|
||||
|
||||
Regression tests for the stale-bridge trap: ``connect()`` reused any
|
||||
already-running bridge with ``status: connected`` unconditionally, and
|
||||
``disconnect()`` only kills bridges the adapter spawned itself. A
|
||||
long-lived bridge process therefore survived gateway restarts AND
|
||||
``hermes update``, serving pre-update bridge.js behavior forever (e.g.
|
||||
no inbound media download → images/voice notes arrive as placeholders).
|
||||
|
||||
The fix: bridge.js reports a hash of its own source in ``/health``
|
||||
(``scriptHash``); the adapter compares it against the bridge.js on disk
|
||||
and restarts the bridge on mismatch. Bridges that predate the handshake
|
||||
report no hash and are treated as stale by definition.
|
||||
|
||||
Also covers the npm dependency-refresh stamp: deps are reinstalled when
|
||||
package.json changes, not only when node_modules is missing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
|
||||
|
||||
class _AsyncCM:
|
||||
"""Minimal async context manager returning a fixed value."""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.value
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _make_adapter(bridge_script: str = "/tmp/test-bridge.js",
|
||||
session_path: Path = Path("/tmp/test-wa-session")):
|
||||
"""Create a WhatsAppAdapter with test attributes (bypass __init__)."""
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||
|
||||
adapter = WhatsAppAdapter.__new__(WhatsAppAdapter)
|
||||
adapter.platform = Platform.WHATSAPP
|
||||
adapter.config = MagicMock()
|
||||
adapter._bridge_port = 19876
|
||||
adapter._bridge_script = bridge_script
|
||||
adapter._session_path = session_path
|
||||
adapter._bridge_log_fh = None
|
||||
adapter._bridge_log = None
|
||||
adapter._bridge_process = None
|
||||
adapter._reply_prefix = None
|
||||
adapter._running = False
|
||||
adapter._message_handler = None
|
||||
adapter._fatal_error_code = None
|
||||
adapter._fatal_error_message = None
|
||||
adapter._fatal_error_retryable = True
|
||||
adapter._fatal_error_handler = None
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._background_tasks = set()
|
||||
adapter._auto_tts_disabled_chats = set()
|
||||
adapter._message_queue = asyncio.Queue()
|
||||
adapter._http_session = None
|
||||
return adapter
|
||||
|
||||
|
||||
def _mock_health(json_data):
|
||||
"""Mock aiohttp.ClientSession whose GET returns 200 + *json_data*."""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value=json_data)
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_AsyncCM(mock_resp))
|
||||
mock_session.close = AsyncMock()
|
||||
return MagicMock(return_value=_AsyncCM(mock_session))
|
||||
|
||||
|
||||
def _setup_bridge_dir(tmp_path: Path) -> Path:
|
||||
"""Create a real bridge dir with bridge.js + package.json + creds."""
|
||||
bridge_dir = tmp_path / "whatsapp-bridge"
|
||||
bridge_dir.mkdir()
|
||||
(bridge_dir / "bridge.js").write_text("// current bridge code\n")
|
||||
(bridge_dir / "package.json").write_text('{"name": "bridge"}\n')
|
||||
session_path = tmp_path / "session"
|
||||
session_path.mkdir()
|
||||
(session_path / "creds.json").write_text("{}")
|
||||
return bridge_dir
|
||||
|
||||
|
||||
def _fresh_node_modules(bridge_dir: Path) -> None:
|
||||
"""Create node_modules with a stamp matching the current package.json."""
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
nm = bridge_dir / "node_modules"
|
||||
nm.mkdir()
|
||||
(nm / ".hermes-pkg-hash").write_text(
|
||||
_file_content_hash(bridge_dir / "package.json")
|
||||
)
|
||||
|
||||
|
||||
class TestFileContentHash:
|
||||
def test_hashes_file(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
f = tmp_path / "x.js"
|
||||
f.write_text("abc")
|
||||
h = _file_content_hash(f)
|
||||
assert len(h) == 16
|
||||
assert h == _file_content_hash(f) # deterministic
|
||||
|
||||
def test_changes_with_content(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
f = tmp_path / "x.js"
|
||||
f.write_text("abc")
|
||||
h1 = _file_content_hash(f)
|
||||
f.write_text("def")
|
||||
assert _file_content_hash(f) != h1
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
assert _file_content_hash(tmp_path / "nope.js") == ""
|
||||
|
||||
def test_matches_bridge_js_self_hash_algorithm(self, tmp_path):
|
||||
"""Python and Node must compute the same hash for the same bytes."""
|
||||
import hashlib
|
||||
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
f = tmp_path / "bridge.js"
|
||||
f.write_bytes(b"const x = 1;\n")
|
||||
# Node side: createHash('sha256').update(bytes).digest('hex').slice(0, 16)
|
||||
expected = hashlib.sha256(b"const x = 1;\n").hexdigest()[:16]
|
||||
assert _file_content_hash(f) == expected
|
||||
|
||||
|
||||
class TestStaleBridgeHandshake:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reuses_bridge_when_hash_matches(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
disk_hash = _file_content_hash(bridge_dir / "bridge.js")
|
||||
mock_client = _mock_health({"status": "connected", "scriptHash": disk_hash})
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", mock_client), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.create_task") as mock_task, \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True), \
|
||||
patch.object(adapter, "_mark_connected", create=True):
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is True
|
||||
mock_popen.assert_not_called() # reused, never spawned
|
||||
mock_task.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restarts_bridge_on_hash_mismatch(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_client = _mock_health(
|
||||
{"status": "connected", "scriptHash": "deadbeefdeadbeef"}
|
||||
)
|
||||
# Spawned bridge dies immediately → connect() returns False, but the
|
||||
# assertion that matters is that the stale bridge was NOT reused and
|
||||
# a new process spawn was attempted.
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", mock_client), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process") as mock_kill_port, \
|
||||
patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is False # mock proc died; not the point of the test
|
||||
mock_popen.assert_called_once() # stale bridge replaced, not reused
|
||||
mock_kill_port.assert_called_once_with(adapter._bridge_port)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restarts_unversioned_bridge(self, tmp_path):
|
||||
"""Bridges predating the handshake report no scriptHash → stale."""
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
# Old bridge /health payload: no scriptHash key at all
|
||||
mock_client = _mock_health({"status": "connected"})
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", mock_client), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
|
||||
|
||||
class TestDepRefreshStamp:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_install_when_stamp_fresh(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.run") as mock_run, \
|
||||
patch("subprocess.Popen", return_value=mock_proc), \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reinstalls_when_package_json_changed(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
# Simulate `hermes update` bumping the Baileys pin
|
||||
(bridge_dir / "package.json").write_text('{"name": "bridge", "v": 2}\n')
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, \
|
||||
patch("subprocess.Popen", return_value=mock_proc), \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_run.assert_called_once()
|
||||
assert "install" in mock_run.call_args[0][0]
|
||||
# Stamp updated to the new package.json hash
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
stamp = (bridge_dir / "node_modules" / ".hermes-pkg-hash").read_text().strip()
|
||||
assert stamp == _file_content_hash(bridge_dir / "package.json")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installs_when_node_modules_missing(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path) # no node_modules
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
def _npm_install(*args, **kwargs):
|
||||
# npm creates node_modules as a side effect
|
||||
(bridge_dir / "node_modules").mkdir(exist_ok=True)
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.run", side_effect=_npm_install) as mock_run, \
|
||||
patch("subprocess.Popen", return_value=mock_proc), \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
class TestCacheDirEnvPassthrough:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_spawn_env_has_cache_dirs(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
env = mock_popen.call_args.kwargs["env"]
|
||||
from gateway.platforms.base import (
|
||||
get_audio_cache_dir,
|
||||
get_document_cache_dir,
|
||||
get_image_cache_dir,
|
||||
)
|
||||
assert env["HERMES_IMAGE_CACHE_DIR"] == str(get_image_cache_dir())
|
||||
assert env["HERMES_AUDIO_CACHE_DIR"] == str(get_audio_cache_dir())
|
||||
assert env["HERMES_DOCUMENT_CACHE_DIR"] == str(get_document_cache_dir())
|
||||
|
|
@ -146,6 +146,12 @@ class TestShouldExclude:
|
|||
from hermes_cli.backup import _should_exclude
|
||||
assert not _should_exclude(Path("logs/agent.log"))
|
||||
|
||||
def test_includes_nested_hermes_agent_in_skills(self):
|
||||
"""skills/autonomous-ai-agents/hermes-agent/ must NOT be excluded —
|
||||
only the root-level hermes-agent/ repo is skipped."""
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert not _should_exclude(Path("skills/autonomous-ai-agents/hermes-agent/SKILL.md"))
|
||||
assert not _should_exclude(Path("skills/autonomous-ai-agents/hermes-agent/sub/item.txt"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup tests
|
||||
|
|
@ -186,6 +192,66 @@ class TestBackup:
|
|||
# Skins
|
||||
assert "skins/cyber.yaml" in names
|
||||
|
||||
def test_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch):
|
||||
"""SQLite staging temp files must be created on the output zip's
|
||||
filesystem (dir=out_path.parent), NOT the system /tmp default — a
|
||||
small tmpfs there silently drops large DBs from the backup (#35376)."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_dir = tmp_path / "external-drive"
|
||||
out_dir.mkdir()
|
||||
out_zip = out_dir / "backup.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
import hermes_cli.backup as backup_mod
|
||||
staged_dirs = []
|
||||
real_ntf = backup_mod.tempfile.NamedTemporaryFile
|
||||
|
||||
def _spy(*a, **kw):
|
||||
staged_dirs.append(kw.get("dir"))
|
||||
return real_ntf(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(backup_mod.tempfile, "NamedTemporaryFile", _spy)
|
||||
backup_mod.run_backup(args)
|
||||
|
||||
# At least one .db was staged, and every staging call targeted the
|
||||
# output zip's directory rather than the system temp default.
|
||||
assert staged_dirs, "no SQLite snapshot was staged"
|
||||
assert all(d == str(out_dir) for d in staged_dirs), staged_dirs
|
||||
|
||||
def test_pre_update_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch):
|
||||
"""The pre-update/pre-migration zip path (_write_full_zip_backup) must
|
||||
also stage SQLite snapshots beside its output zip, not in /tmp."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = hermes_home / "backups" / "pre-update-test.zip"
|
||||
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import hermes_cli.backup as backup_mod
|
||||
staged_dirs = []
|
||||
real_ntf = backup_mod.tempfile.NamedTemporaryFile
|
||||
|
||||
def _spy(*a, **kw):
|
||||
staged_dirs.append(kw.get("dir"))
|
||||
return real_ntf(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(backup_mod.tempfile, "NamedTemporaryFile", _spy)
|
||||
result = backup_mod._write_full_zip_backup(out_zip, hermes_home)
|
||||
|
||||
assert result is not None
|
||||
assert staged_dirs, "no SQLite snapshot was staged"
|
||||
assert all(d == str(out_zip.parent) for d in staged_dirs), staged_dirs
|
||||
|
||||
def test_excludes_hermes_agent(self, tmp_path, monkeypatch):
|
||||
"""Backup does NOT include hermes-agent/ directory."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
|
|
@ -206,6 +272,37 @@ class TestBackup:
|
|||
agent_files = [n for n in names if "hermes-agent" in n]
|
||||
assert agent_files == [], f"hermes-agent files leaked into backup: {agent_files}"
|
||||
|
||||
def test_includes_nested_hermes_agent_in_skills(self, tmp_path, monkeypatch):
|
||||
"""Backup includes skills/.../hermes-agent/ but NOT root hermes-agent/."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
|
||||
# Add a nested hermes-agent directory inside skills (like the real layout)
|
||||
nested = hermes_home / "skills" / "autonomous-ai-agents" / "hermes-agent"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "SKILL.md").write_text("# Hermes Agent Skill\n")
|
||||
(nested / "sub").mkdir()
|
||||
(nested / "sub" / "item.txt").write_text("nested content\n")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = tmp_path / "backup.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
with zipfile.ZipFile(out_zip, "r") as zf:
|
||||
names = zf.namelist()
|
||||
# Root hermes-agent must be excluded
|
||||
root_agent = [n for n in names if n.startswith("hermes-agent/")]
|
||||
assert root_agent == [], f"root hermes-agent leaked: {root_agent}"
|
||||
# Nested skill hermes-agent must be included
|
||||
assert "skills/autonomous-ai-agents/hermes-agent/SKILL.md" in names
|
||||
assert "skills/autonomous-ai-agents/hermes-agent/sub/item.txt" in names
|
||||
|
||||
def test_excludes_pycache(self, tmp_path, monkeypatch):
|
||||
"""Backup does NOT include __pycache__ dirs."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
|
|
|
|||
|
|
@ -691,6 +691,169 @@ class TestSubcommandCompletion:
|
|||
completions = _completions(SlashCommandCompleter(), "/help ")
|
||||
assert completions == []
|
||||
|
||||
def test_tools_subcommand_completion(self):
|
||||
"""`/tools ` should suggest list, disable, enable."""
|
||||
completions = _completions(SlashCommandCompleter(), "/tools ")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"list", "disable", "enable"}
|
||||
|
||||
def test_tools_subcommand_prefix_filters(self):
|
||||
completions = _completions(SlashCommandCompleter(), "/tools en")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"enable"}
|
||||
|
||||
def test_tools_enable_completes_toolset_names(self, monkeypatch):
|
||||
"""`/tools enable ` should suggest currently-disabled toolsets."""
|
||||
from hermes_cli import commands as commands_mod
|
||||
|
||||
# `web` is enabled, `spotify` is disabled — enabling should only offer
|
||||
# the disabled ones.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
lambda *_a, **_k: {"web", "file"},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
completions = _completions(SlashCommandCompleter(), "/tools enable ")
|
||||
texts = {c.text for c in completions}
|
||||
# Should include disabled toolsets, exclude already-enabled ones.
|
||||
assert "web" not in texts
|
||||
assert "file" not in texts
|
||||
assert "spotify" in texts
|
||||
|
||||
def test_tools_disable_completes_enabled_toolsets_only(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
lambda *_a, **_k: {"web", "file"},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
completions = _completions(SlashCommandCompleter(), "/tools disable ")
|
||||
texts = {c.text for c in completions}
|
||||
# Should include enabled toolsets, exclude disabled ones.
|
||||
assert texts == {"web", "file"}
|
||||
|
||||
def test_tools_enable_partial_filters(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
lambda *_a, **_k: set(),
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
completions = _completions(SlashCommandCompleter(), "/tools enable sp")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"spotify"}
|
||||
|
||||
def test_tools_enable_skips_already_listed(self, monkeypatch):
|
||||
"""If the user already typed a name, don't suggest it again."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
lambda *_a, **_k: set(),
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
completions = _completions(SlashCommandCompleter(), "/tools enable spotify ")
|
||||
texts = {c.text for c in completions}
|
||||
assert "spotify" not in texts
|
||||
|
||||
def test_tools_suggests_mcp_server_prefixes(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
lambda *_a, **_k: set(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"mcp_servers": {"github": {}, "linear": {}}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
completions = _completions(SlashCommandCompleter(), "/tools enable git")
|
||||
texts = {c.text for c in completions}
|
||||
assert "github:" in texts
|
||||
|
||||
def _fake_gateway(self, monkeypatch, platforms):
|
||||
"""Patch load_gateway_config with a fake whose connected platforms are
|
||||
the keys of `platforms` (name -> home as None or a (chat_id, name) tuple).
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
enums = {name: SimpleNamespace(value=name) for name in platforms}
|
||||
homes = {
|
||||
name: (None if home is None else SimpleNamespace(chat_id=home[0], name=home[1]))
|
||||
for name, home in platforms.items()
|
||||
}
|
||||
fake = SimpleNamespace(
|
||||
get_connected_platforms=lambda: list(enums.values()),
|
||||
get_home_channel=lambda p: homes[p.value],
|
||||
)
|
||||
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: fake)
|
||||
|
||||
def test_handoff_completes_connected_platforms(self, monkeypatch):
|
||||
"""`/handoff ` offers connected platforms, with or without a home channel."""
|
||||
self._fake_gateway(
|
||||
monkeypatch,
|
||||
{
|
||||
"telegram": ("123", "Me"),
|
||||
"discord": None, # no home channel yet -> still listed
|
||||
},
|
||||
)
|
||||
|
||||
texts = {c.text for c in _completions(SlashCommandCompleter(), "/handoff ")}
|
||||
assert texts == {"telegram", "discord"}
|
||||
|
||||
def test_handoff_filters_by_prefix(self, monkeypatch):
|
||||
self._fake_gateway(
|
||||
monkeypatch,
|
||||
{
|
||||
"telegram": ("1", "H"),
|
||||
"signal": ("2", "H"),
|
||||
},
|
||||
)
|
||||
|
||||
texts = {c.text for c in _completions(SlashCommandCompleter(), "/handoff te")}
|
||||
assert texts == {"telegram"}
|
||||
|
||||
def test_handoff_no_completion_after_platform_chosen(self, monkeypatch):
|
||||
self._fake_gateway(monkeypatch, {"telegram": ("1", "H")})
|
||||
assert _completions(SlashCommandCompleter(), "/handoff telegram ") == []
|
||||
|
||||
def test_handoff_completion_swallows_config_errors(self, monkeypatch):
|
||||
def _boom():
|
||||
raise RuntimeError("no gateway config")
|
||||
|
||||
monkeypatch.setattr("gateway.config.load_gateway_config", _boom)
|
||||
assert _completions(SlashCommandCompleter(), "/handoff ") == []
|
||||
|
||||
def test_personality_completes_configured_personalities(self):
|
||||
"""`/personality ` lists real personalities, not just `none`.
|
||||
|
||||
Regression: the completer read load_config().agent.personalities, a path
|
||||
that never exists, so it always came back empty. It must resolve from the
|
||||
CLI config the runtime actually applies (which ships built-ins).
|
||||
"""
|
||||
texts = {c.text for c in _completions(SlashCommandCompleter(), "/personality ")}
|
||||
assert "none" in texts
|
||||
assert len(texts) > 1
|
||||
|
||||
|
||||
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ class TestCronCommandLifecycle:
|
|||
repeat=None,
|
||||
skill=None,
|
||||
skills=["maps", "blogwatcher"],
|
||||
profile="default",
|
||||
clear_skills=False,
|
||||
)
|
||||
)
|
||||
|
|
@ -64,7 +63,6 @@ class TestCronCommandLifecycle:
|
|||
assert updated["name"] == "Edited Job"
|
||||
assert updated["prompt"] == "Revised prompt"
|
||||
assert updated["schedule_display"] == "every 120m"
|
||||
assert updated["profile"] == "default"
|
||||
|
||||
cron_command(
|
||||
Namespace(
|
||||
|
|
@ -77,14 +75,12 @@ class TestCronCommandLifecycle:
|
|||
repeat=None,
|
||||
skill=None,
|
||||
skills=None,
|
||||
profile="",
|
||||
clear_skills=True,
|
||||
)
|
||||
)
|
||||
cleared = get_job(job["id"])
|
||||
assert cleared["skills"] == []
|
||||
assert cleared["skill"] is None
|
||||
assert cleared["profile"] is None
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Updated job" in out
|
||||
|
|
@ -100,7 +96,6 @@ class TestCronCommandLifecycle:
|
|||
repeat=None,
|
||||
skill=None,
|
||||
skills=["blogwatcher", "maps"],
|
||||
profile="default",
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
|
|
@ -110,7 +105,6 @@ class TestCronCommandLifecycle:
|
|||
assert len(jobs) == 1
|
||||
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
|
||||
assert jobs[0]["name"] == "Skill combo"
|
||||
assert jobs[0]["profile"] == "default"
|
||||
|
||||
def test_list_does_not_crash_when_repeat_is_null(self, tmp_cron_dir, capsys):
|
||||
"""A one-shot job can be persisted with ``"repeat": null``. `cron
|
||||
|
|
|
|||
|
|
@ -47,20 +47,19 @@ def test_cron_aliases():
|
|||
def test_cron_create_options():
|
||||
parser = _build()
|
||||
ns = parser.parse_args([
|
||||
"cron", "create", "0 9 * * *", "do the thing",
|
||||
"cron", "create", "0 9 * * *", "daily task prompt",
|
||||
"--name", "daily", "--deliver", "origin", "--repeat", "3",
|
||||
"--skill", "a", "--skill", "b", "--no-agent",
|
||||
"--workdir", "/tmp/x", "--profile", "work",
|
||||
"--workdir", "/tmp/x",
|
||||
])
|
||||
assert ns.schedule == "0 9 * * *"
|
||||
assert ns.prompt == "do the thing"
|
||||
assert ns.prompt == "daily task prompt"
|
||||
assert ns.name == "daily"
|
||||
assert ns.deliver == "origin"
|
||||
assert ns.repeat == 3
|
||||
assert ns.skills == ["a", "b"]
|
||||
assert ns.no_agent is True
|
||||
assert ns.workdir == "/tmp/x"
|
||||
assert ns.profile == "work"
|
||||
|
||||
|
||||
def test_cron_edit_no_agent_tristate():
|
||||
|
|
@ -201,6 +201,91 @@ class TestWebhookEndpoints:
|
|||
r = self.client.post("/api/webhooks", json={"name": "gh", "deliver": "log"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_enable_platform_starts_gateway_restart(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
ws._ACTION_PROCS.pop("gateway-restart", None)
|
||||
restart_calls = []
|
||||
|
||||
class FakeRestartProc:
|
||||
pid = 4242
|
||||
|
||||
def fake_spawn_action(subcommand, name):
|
||||
restart_calls.append((subcommand, name))
|
||||
return FakeRestartProc()
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn_action)
|
||||
|
||||
r = self.client.post("/api/webhooks/enable")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"platform": "webhook",
|
||||
"enabled": True,
|
||||
"needs_restart": False,
|
||||
"restart_started": True,
|
||||
"restart_action": "gateway-restart",
|
||||
"restart_pid": 4242,
|
||||
}
|
||||
assert restart_calls == [(["gateway", "restart"], "gateway-restart")]
|
||||
assert load_config()["platforms"]["webhook"]["enabled"] is True
|
||||
assert self.client.get("/api/webhooks").json()["enabled"] is True
|
||||
|
||||
def test_enable_platform_reports_restart_failure_after_save(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
ws._ACTION_PROCS.pop("gateway-restart", None)
|
||||
|
||||
def fail_spawn_action(subcommand, name):
|
||||
assert subcommand == ["gateway", "restart"]
|
||||
assert name == "gateway-restart"
|
||||
raise RuntimeError("supervisor unavailable")
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
|
||||
|
||||
r = self.client.post("/api/webhooks/enable")
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["platform"] == "webhook"
|
||||
assert data["enabled"] is True
|
||||
assert data["needs_restart"] is True
|
||||
assert data["restart_started"] is False
|
||||
assert "supervisor unavailable" in data["restart_error"]
|
||||
assert load_config()["platforms"]["webhook"]["enabled"] is True
|
||||
|
||||
def test_enable_platform_reuses_inflight_gateway_restart(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
ws._ACTION_PROCS.pop("gateway-restart", None)
|
||||
|
||||
class FakeRunningProc:
|
||||
pid = 5151
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setitem(ws._ACTION_PROCS, "gateway-restart", FakeRunningProc())
|
||||
|
||||
def fail_spawn_action(subcommand, name):
|
||||
raise AssertionError("must not spawn a second concurrent restart")
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
|
||||
|
||||
r = self.client.post("/api/webhooks/enable")
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["needs_restart"] is False
|
||||
assert data["restart_started"] is True
|
||||
assert data["restart_pid"] == 5151
|
||||
assert load_config()["platforms"]["webhook"]["enabled"] is True
|
||||
|
||||
|
||||
class TestOpsEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -622,6 +707,10 @@ class TestAdminEndpointsAuthGate:
|
|||
resp = self.client.get(path)
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
def test_webhooks_enable_post_gated(self):
|
||||
resp = self.client.post("/api/webhooks/enable")
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
class TestUpdateCheckEndpoint:
|
||||
"""``GET /api/hermes/update/check`` reports availability without applying.
|
||||
|
|
@ -953,4 +1042,3 @@ class TestToolsConfigEndpoints:
|
|||
kwargs["json"] = payload
|
||||
r = fn(path, **kwargs)
|
||||
assert r.status_code == 401, f"{method} {path} not gated"
|
||||
|
||||
|
|
|
|||
130
tests/hermes_cli/test_dashboard_unified_launch.py
Normal file
130
tests/hermes_cli/test_dashboard_unified_launch.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Tests for the unified profile→machine dashboard launch routing.
|
||||
|
||||
`<profile> dashboard` routes to ONE machine-level dashboard instead of
|
||||
spawning a per-profile server: attach (open browser at ?profile=) when one
|
||||
is already listening, else re-exec as the machine dashboard with the
|
||||
launching profile preselected. `--isolated` opts out.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_mod():
|
||||
import hermes_cli.main as main_mod
|
||||
return main_mod
|
||||
|
||||
|
||||
def _args(**kw):
|
||||
defaults = dict(
|
||||
status=False, stop=False, host="127.0.0.1", port=9119,
|
||||
no_open=True, insecure=False, skip_build=False,
|
||||
isolated=False, open_profile="",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return types.SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
class TestUnifiedDashboardRouting:
|
||||
def test_profile_launch_attaches_to_running_dashboard(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
|
||||
execs = []
|
||||
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.cmd_dashboard(_args())
|
||||
assert exc.value.code == 0
|
||||
assert execs == [] # attached, never re-exec'd
|
||||
|
||||
def test_profile_launch_attach_opens_scoped_url(self, main_mod, monkeypatch):
|
||||
"""The attach path must open the browser at ?profile=<name> — that
|
||||
URL is the entire point of attaching (preselects the switcher)."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
|
||||
opened = []
|
||||
import webbrowser
|
||||
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.cmd_dashboard(_args(no_open=False))
|
||||
assert exc.value.code == 0
|
||||
assert opened == ["http://127.0.0.1:9119/?profile=worker_x"]
|
||||
|
||||
def test_profile_launch_reexecs_machine_dashboard(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False)
|
||||
execs = []
|
||||
|
||||
def fake_exec(exe, argv, env):
|
||||
execs.append((exe, argv, env))
|
||||
raise SystemExit(0) # execvpe never returns
|
||||
|
||||
monkeypatch.setattr(main_mod.os, "execvpe", fake_exec)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_dashboard(_args())
|
||||
|
||||
assert len(execs) == 1
|
||||
exe, argv, env = execs[0]
|
||||
assert exe == sys.executable
|
||||
# Pinned to the default profile + launching profile preselected.
|
||||
assert "-p" in argv and argv[argv.index("-p") + 1] == "default"
|
||||
assert "--open-profile" in argv
|
||||
assert argv[argv.index("--open-profile") + 1] == "worker_x"
|
||||
# Profile HERMES_HOME dropped so the child binds the machine root.
|
||||
assert "HERMES_HOME" not in env
|
||||
|
||||
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
listening_calls = []
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_dashboard_listening",
|
||||
lambda host, port: listening_calls.append(1) or True,
|
||||
)
|
||||
# With --isolated the routing block is skipped entirely; the command
|
||||
# proceeds to dependency checks. Make the first post-routing step
|
||||
# bail so the test doesn't actually start a server.
|
||||
monkeypatch.setitem(sys.modules, "fastapi", None)
|
||||
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args(isolated=True))
|
||||
assert listening_calls == []
|
||||
|
||||
def test_default_profile_launch_skips_routing(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
|
||||
)
|
||||
listening_calls = []
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_dashboard_listening",
|
||||
lambda host, port: listening_calls.append(1) or True,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "fastapi", None)
|
||||
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args())
|
||||
assert listening_calls == []
|
||||
|
||||
def test_reexec_child_does_not_reroute(self, main_mod, monkeypatch):
|
||||
"""The re-exec'd child carries --open-profile; the guard must treat
|
||||
that as 'already routed' and never re-exec again (no exec loop)."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
execs = []
|
||||
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
|
||||
monkeypatch.setitem(sys.modules, "fastapi", None)
|
||||
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
|
||||
assert execs == []
|
||||
|
|
@ -369,6 +369,16 @@ def test_systemd_install_checks_linger_status(monkeypatch, tmp_path, capsys):
|
|||
unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service"
|
||||
|
||||
monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
# Synthetic unit with a non-temp home: the real generator bakes the
|
||||
# hermetic test HERMES_HOME (a tmp dir), which the temp-home write
|
||||
# guard correctly refuses.
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: (
|
||||
'[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
|
||||
),
|
||||
)
|
||||
|
||||
calls = []
|
||||
helper_calls = []
|
||||
|
|
@ -396,6 +406,15 @@ def test_systemd_install_can_skip_enable_on_startup(monkeypatch, tmp_path, capsy
|
|||
unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service"
|
||||
|
||||
monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
# Non-temp home so the temp-home write guard (which trips on the
|
||||
# hermetic test HERMES_HOME) stays out of the way.
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: (
|
||||
'[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
|
||||
),
|
||||
)
|
||||
|
||||
calls = []
|
||||
helper_calls = []
|
||||
|
|
|
|||
|
|
@ -102,6 +102,15 @@ def test_systemd_install_calls_linger_helper(monkeypatch, tmp_path, capsys):
|
|||
unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service"
|
||||
|
||||
monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
# Non-temp home so the temp-home write guard (which trips on the
|
||||
# hermetic test HERMES_HOME) stays out of the way.
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: (
|
||||
'[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
|
||||
),
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
|
|
|
|||
|
|
@ -289,6 +289,105 @@ class TestSystemdServiceRefresh:
|
|||
"daemon-reload" in str(c) for c in ran
|
||||
), "daemon-reload must not run when write was refused"
|
||||
|
||||
def test_refresh_refuses_to_bake_any_tempdir_home_into_real_user_unit(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Structural guard: a manual E2E HERMES_HOME like
|
||||
``/tmp/hermes-e2e-41264`` carries none of the pytest markers but
|
||||
poisons the unit identically (seen live 2026-06-11 — an E2E probe ran
|
||||
``hermes gateway restart`` with a /tmp HERMES_HOME exported; the
|
||||
restart's unit refresh baked it into the production unit and the
|
||||
post-update restart produced a 7-hour zombie gateway). The refresh
|
||||
must refuse ANY temp-dir HERMES_HOME, not just pytest-shaped ones.
|
||||
"""
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("old unit\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path
|
||||
)
|
||||
polluted_unit = (
|
||||
"[Service]\n"
|
||||
'Environment="HERMES_HOME=/tmp/hermes-e2e-41264"\n'
|
||||
"WorkingDirectory=/tmp/hermes-e2e-41264\n"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: polluted_unit,
|
||||
)
|
||||
|
||||
ran = []
|
||||
|
||||
def fake_run(cmd, check=True, **kwargs):
|
||||
ran.append(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
result = gateway_cli.refresh_systemd_unit_if_needed(system=False)
|
||||
|
||||
assert result is False, "refresh should refuse to write a temp-home unit"
|
||||
assert (
|
||||
unit_path.read_text(encoding="utf-8") == "old unit\n"
|
||||
), "installed unit must be left untouched"
|
||||
assert not any(
|
||||
"daemon-reload" in str(c) for c in ran
|
||||
), "daemon-reload must not run when write was refused"
|
||||
|
||||
|
||||
class TestTempHomeServiceDefinitionGuard:
|
||||
"""_temp_home_in_service_definition() — structural temp-dir detection."""
|
||||
|
||||
def test_detects_tmp_home_in_systemd_unit(self):
|
||||
unit = '[Service]\nEnvironment="HERMES_HOME=/tmp/hermes-e2e-41264"\n'
|
||||
assert (
|
||||
gateway_cli._temp_home_in_service_definition(unit)
|
||||
== "/tmp/hermes-e2e-41264"
|
||||
)
|
||||
|
||||
def test_detects_var_tmp_home(self):
|
||||
unit = '[Service]\nEnvironment="HERMES_HOME=/var/tmp/hermes-x"\n'
|
||||
assert gateway_cli._temp_home_in_service_definition(unit) is not None
|
||||
|
||||
def test_detects_tempdir_env_home(self, monkeypatch, tmp_path):
|
||||
import tempfile as _tempfile
|
||||
|
||||
monkeypatch.setattr(_tempfile, "gettempdir", lambda: str(tmp_path))
|
||||
unit = f'[Service]\nEnvironment="HERMES_HOME={tmp_path}/hermes-home"\n'
|
||||
assert gateway_cli._temp_home_in_service_definition(unit) is not None
|
||||
|
||||
def test_detects_tmp_home_in_launchd_plist(self):
|
||||
plist = (
|
||||
"<dict>\n <key>HERMES_HOME</key>\n"
|
||||
" <string>/tmp/hermes-e2e-99999</string>\n</dict>\n"
|
||||
)
|
||||
assert (
|
||||
gateway_cli._temp_home_in_service_definition(plist)
|
||||
== "/tmp/hermes-e2e-99999"
|
||||
)
|
||||
|
||||
def test_accepts_real_home(self):
|
||||
unit = '[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
|
||||
assert gateway_cli._temp_home_in_service_definition(unit) is None
|
||||
|
||||
def test_accepts_macos_real_home_plist(self):
|
||||
plist = (
|
||||
"<dict>\n <key>HERMES_HOME</key>\n"
|
||||
" <string>/Users/alice/.hermes</string>\n</dict>\n"
|
||||
)
|
||||
assert gateway_cli._temp_home_in_service_definition(plist) is None
|
||||
|
||||
def test_accepts_unit_without_hermes_home(self):
|
||||
unit = "[Service]\nExecStart=/usr/bin/python -m hermes_cli.main gateway run\n"
|
||||
assert gateway_cli._temp_home_in_service_definition(unit) is None
|
||||
|
||||
def test_tmp_prefixed_non_temp_path_is_accepted(self):
|
||||
# /tmpfs-data is NOT under /tmp — prefix matching must be
|
||||
# component-wise, not string startswith.
|
||||
unit = '[Service]\nEnvironment="HERMES_HOME=/tmpfs-data/.hermes"\n'
|
||||
assert gateway_cli._temp_home_in_service_definition(unit) is None
|
||||
|
||||
|
||||
class TestRequireServiceInstalled:
|
||||
def test_exits_with_install_hint_when_unit_missing(self, tmp_path, monkeypatch, capsys):
|
||||
|
|
@ -481,6 +580,17 @@ class TestLaunchdServiceRecovery:
|
|||
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
# Patch the generator with synthetic content carrying a real-looking
|
||||
# home — the temp-home guard refuses to write plists whose
|
||||
# HERMES_HOME resolves under the (pytest tmp) test HERMES_HOME.
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"generate_launchd_plist",
|
||||
lambda: (
|
||||
"<plist>--replace\n<key>HERMES_HOME</key>"
|
||||
"<string>/Users/alice/.hermes</string></plist>"
|
||||
),
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
|
|
@ -495,7 +605,10 @@ class TestLaunchdServiceRecovery:
|
|||
label = gateway_cli.get_launchd_label()
|
||||
domain = gateway_cli._launchd_domain()
|
||||
assert "--replace" in plist_path.read_text(encoding="utf-8")
|
||||
assert calls[:2] == [
|
||||
# The calls list includes launchctl print probes from _launchd_domain()
|
||||
# before the bootout/bootstrap calls. Filter to only bootout/bootstrap.
|
||||
service_calls = [c for c in calls if "bootout" in c or "bootstrap" in c]
|
||||
assert service_calls[:2] == [
|
||||
["launchctl", "bootout", f"{domain}/{label}"],
|
||||
["launchctl", "bootstrap", domain, str(plist_path)],
|
||||
]
|
||||
|
|
@ -679,10 +792,22 @@ class TestLaunchdServiceRecovery:
|
|||
assert "stale" in output.lower()
|
||||
assert "not loaded" in output.lower()
|
||||
|
||||
def test_launchd_domain_uses_user_domain(self):
|
||||
def test_launchd_domain_uses_user_domain(self, monkeypatch):
|
||||
# The user/<uid> domain (not gui/<uid>) is the one reachable from
|
||||
# non-Aqua/background sessions on macOS 26+ (issue #23387).
|
||||
assert gateway_cli._launchd_domain() == f"user/{os.getuid()}"
|
||||
# When gui/<uid> fails to probe and user/<uid> succeeds,
|
||||
# _launchd_domain() must return user/<uid>.
|
||||
gateway_cli._resolved_launchd_domain = None
|
||||
monkeypatch.setattr(os, "getuid", lambda: 501)
|
||||
label = gateway_cli.get_launchd_label()
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if "print" in cmd and "gui/" in " ".join(cmd):
|
||||
raise subprocess.CalledProcessError(1, cmd, stderr="Domain error")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
assert gateway_cli._launchd_domain() == "user/501"
|
||||
|
||||
def test_launchctl_domain_unsupported_recognizes_macos26_codes(self):
|
||||
# Codes that persist after a fresh bootstrap → launchd truly unavailable.
|
||||
|
|
@ -761,6 +886,17 @@ class TestLaunchdServiceRecovery:
|
|||
"""macOS bootstrap error 5 should spawn a detached gateway, not crash."""
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
# Synthetic plist with a non-temp home so the temp-home write guard
|
||||
# (which would trip on the pytest-tmp test HERMES_HOME) stays out of
|
||||
# the way — this test exercises the bootstrap-error fallback.
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"generate_launchd_plist",
|
||||
lambda: (
|
||||
"<plist><key>HERMES_HOME</key>"
|
||||
"<string>/Users/alice/.hermes</string></plist>"
|
||||
),
|
||||
)
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if cmd[:2] == ["launchctl", "bootstrap"]:
|
||||
|
|
@ -836,6 +972,114 @@ class TestLaunchdServiceRecovery:
|
|||
assert "nohup hermes gateway run" in out
|
||||
|
||||
|
||||
class TestLaunchdDomainDetection:
|
||||
"""Regression tests for _launchd_domain() probing (#40831).
|
||||
|
||||
The function must detect which launchd domain actually contains (or can
|
||||
manage) the service, rather than hardcoding ``user/<uid>`` or ``gui/<uid>``.
|
||||
"""
|
||||
|
||||
def _reset_domain_cache(self):
|
||||
"""Clear any cached domain result between tests."""
|
||||
gateway_cli._resolved_launchd_domain = None
|
||||
|
||||
def test_prefers_gui_domain_when_service_loaded_there(self, monkeypatch):
|
||||
"""In an Aqua session where the service is loaded under gui/<uid>,
|
||||
_launchd_domain() must return ``gui/<uid>`` — not ``user/<uid>``."""
|
||||
self._reset_domain_cache()
|
||||
monkeypatch.setattr(os, "getuid", lambda: 501)
|
||||
label = gateway_cli.get_launchd_label()
|
||||
|
||||
run_calls = []
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
domain = gateway_cli._launchd_domain()
|
||||
assert domain == f"gui/501"
|
||||
# Should have probed gui first
|
||||
assert run_calls[0] == ["launchctl", "print", f"gui/501/{label}"]
|
||||
|
||||
def test_falls_back_to_user_domain_when_gui_fails(self, monkeypatch):
|
||||
"""In a Background/SSH session where gui/<uid> fails but user/<uid>
|
||||
works, _launchd_domain() must return ``user/<uid>``."""
|
||||
self._reset_domain_cache()
|
||||
monkeypatch.setattr(os, "getuid", lambda: 501)
|
||||
label = gateway_cli.get_launchd_label()
|
||||
|
||||
run_calls = []
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
if "print" in cmd and "gui/" in " ".join(cmd):
|
||||
raise subprocess.CalledProcessError(1, cmd, stderr="Domain error")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
domain = gateway_cli._launchd_domain()
|
||||
assert domain == f"user/501"
|
||||
# Should have tried gui first, then user
|
||||
assert len(run_calls) >= 2
|
||||
|
||||
def test_uses_managername_heuristic_when_both_probe_fail(self, monkeypatch):
|
||||
"""When neither domain contains a loaded service, use
|
||||
``launchctl managername`` as a tiebreaker: Aqua -> gui, else -> user."""
|
||||
self._reset_domain_cache()
|
||||
monkeypatch.setattr(os, "getuid", lambda: 501)
|
||||
label = gateway_cli.get_launchd_label()
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if "print" in cmd:
|
||||
raise subprocess.CalledProcessError(1, cmd, stderr="not found")
|
||||
if "managername" in cmd:
|
||||
return SimpleNamespace(returncode=0, stdout="Aqua\n", stderr="")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
domain = gateway_cli._launchd_domain()
|
||||
assert domain == f"gui/501"
|
||||
|
||||
def test_managername_background_selects_user_domain(self, monkeypatch):
|
||||
"""When managername is Background (non-Aqua), use user/<uid>."""
|
||||
self._reset_domain_cache()
|
||||
monkeypatch.setattr(os, "getuid", lambda: 501)
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if "print" in cmd:
|
||||
raise subprocess.CalledProcessError(1, cmd, stderr="not found")
|
||||
if "managername" in cmd:
|
||||
return SimpleNamespace(returncode=0, stdout="Background\n", stderr="")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
domain = gateway_cli._launchd_domain()
|
||||
assert domain == f"user/501"
|
||||
|
||||
def test_caches_result_across_calls(self, monkeypatch):
|
||||
"""Domain detection should run once and cache the result."""
|
||||
self._reset_domain_cache()
|
||||
monkeypatch.setattr(os, "getuid", lambda: 501)
|
||||
|
||||
run_count = [0]
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
run_count[0] += 1
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
d1 = gateway_cli._launchd_domain()
|
||||
d2 = gateway_cli._launchd_domain()
|
||||
assert d1 == d2
|
||||
assert run_count[0] == 1 # Only probed once
|
||||
|
||||
|
||||
class TestGatewayServiceDetection:
|
||||
def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
|
|
|
|||
|
|
@ -712,6 +712,76 @@ def test_named_custom_provider_uses_saved_credentials(monkeypatch):
|
|||
assert resolved["source"] == "custom_provider:Local"
|
||||
|
||||
|
||||
def test_bare_custom_resolves_providers_dict_entry_named_custom(monkeypatch):
|
||||
"""A request for bare ``provider="custom"`` must resolve a literal
|
||||
``providers.custom`` entry (e.g. a cliproxy endpoint) instead of falling
|
||||
through to the global default. Regression for cron jobs stored with
|
||||
``provider: "custom"`` failing with ``auth_unavailable: providers=codex``.
|
||||
"""
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"providers": {
|
||||
"custom": {
|
||||
"api": "https://cliproxy.example.com/v1",
|
||||
"api_key": "cliproxy-key",
|
||||
"default_model": "gpt-5.4",
|
||||
"name": "CLIProxy",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
# Reaching resolve_provider for bare custom with a matching entry means the
|
||||
# named-custom path was bypassed — that is the bug we are fixing.
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"resolve_provider",
|
||||
lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError(
|
||||
"resolve_provider must not be called; providers.custom should match"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="custom")
|
||||
|
||||
assert resolved["provider"] == "custom"
|
||||
assert resolved["base_url"] == "https://cliproxy.example.com/v1"
|
||||
assert resolved["api_key"] == "cliproxy-key"
|
||||
assert resolved["requested_provider"] == "custom"
|
||||
|
||||
|
||||
def test_bare_custom_without_named_entry_still_falls_through(monkeypatch):
|
||||
"""No literal providers.custom entry → bare custom keeps the legacy
|
||||
model.base_url trust-path behavior, unchanged by the fix."""
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_get_model_config",
|
||||
lambda: {
|
||||
"provider": "openrouter",
|
||||
"base_url": "http://127.0.0.1:8082/v1",
|
||||
"default": "my-local-model",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {"providers": {"some-other-proxy": {"api": "https://x.example/v1"}}},
|
||||
)
|
||||
monkeypatch.delenv("CUSTOM_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="custom")
|
||||
|
||||
assert resolved["provider"] == "custom"
|
||||
assert resolved["base_url"] == "http://127.0.0.1:8082/v1"
|
||||
|
||||
|
||||
def test_named_custom_provider_uses_providers_dict_when_list_missing(monkeypatch):
|
||||
"""After v11→v12 migration deletes custom_providers, resolution should
|
||||
still find entries in the providers dict via get_compatible_custom_providers."""
|
||||
|
|
|
|||
|
|
@ -975,6 +975,19 @@ def test_toolset_has_keys_treats_no_key_providers_as_configured():
|
|||
assert _toolset_has_keys("computer_use", config) is True
|
||||
|
||||
|
||||
def test_web_no_prompt_when_usable_keyless():
|
||||
"""Fresh install: web works via the free Parallel MCP, so enabling the web
|
||||
toolset should not force provider setup."""
|
||||
with patch("tools.web_tools.check_web_api_key", return_value=True):
|
||||
assert _toolset_needs_configuration_prompt("web", {}) is False
|
||||
|
||||
|
||||
def test_web_no_prompt_when_extract_backend_is_extract_capable():
|
||||
with patch("tools.web_tools.check_web_api_key", return_value=True):
|
||||
cfg = {"web": {"extract_backend": "parallel"}}
|
||||
assert _toolset_needs_configuration_prompt("web", cfg) is False
|
||||
|
||||
|
||||
def test_computer_use_needs_configuration_when_cua_driver_post_setup_pending():
|
||||
"""No-key providers can still need setup when their post_setup is unsatisfied.
|
||||
|
||||
|
|
|
|||
|
|
@ -425,3 +425,43 @@ def test_tui_launch_install_uses_workspace_scope(
|
|||
install_cmd = npm_calls[0]
|
||||
assert "--workspace" in install_cmd
|
||||
assert "ui-tui" in install_cmd
|
||||
|
||||
def test_make_tui_argv_omits_workspace_when_tui_has_own_lockfile(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
"""When ui-tui/ has its own package-lock.json, _workspace_root returns
|
||||
tui_dir itself. npm install --workspace ui-tui would fail in that case
|
||||
because npm cannot find a workspace named "ui-tui" inside ui-tui/.
|
||||
The fix omits --workspace and runs plain npm install from tui_dir.
|
||||
See #42973.
|
||||
"""
|
||||
tui_dir = tmp_path / "ui-tui"
|
||||
tui_dir.mkdir()
|
||||
(tui_dir / "package.json").write_text("{}")
|
||||
# Simulate curl-install layout: tui_dir has its own lockfile
|
||||
(tui_dir / "package-lock.json").write_text("{}")
|
||||
# Parent also has lockfile (but _workspace_root prefers tui_dir's own)
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
main_mod._make_tui_argv(tui_dir, tui_dev=False)
|
||||
|
||||
install_cmd = calls[0][0][0]
|
||||
# Must NOT contain --workspace when npm_cwd == tui_dir
|
||||
assert "--workspace" not in install_cmd, (
|
||||
f"npm install should omit --workspace when tui_dir has its own lockfile, got: {install_cmd}"
|
||||
)
|
||||
assert install_cmd[:2] == ["/bin/npm", "install"]
|
||||
# cwd must be tui_dir (standalone), not parent
|
||||
assert calls[0][1]["cwd"] == str(tui_dir)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,39 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
|
|||
result = check_for_updates()
|
||||
|
||||
assert result == 5
|
||||
assert mock_run.call_count == 2 # git fetch + git rev-list
|
||||
assert mock_run.call_count == 3 # origin probe + git fetch + git rev-list
|
||||
|
||||
|
||||
def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path):
|
||||
"""Passive update checks must not trigger SSH auth for official installs."""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
repo_dir = tmp_path / "hermes-agent"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / ".git").mkdir()
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
if cmd == ["git", "remote", "get-url", "origin"]:
|
||||
return MagicMock(returncode=0, stdout="git@github.com:NousResearch/hermes-agent.git\n")
|
||||
if cmd == ["git", "rev-parse", "HEAD"]:
|
||||
return MagicMock(returncode=0, stdout="local-sha\n")
|
||||
if cmd == [
|
||||
"git",
|
||||
"ls-remote",
|
||||
"https://github.com/NousResearch/hermes-agent.git",
|
||||
"refs/heads/main",
|
||||
]:
|
||||
return MagicMock(returncode=0, stdout="upstream-sha\trefs/heads/main\n")
|
||||
raise AssertionError(f"unexpected git command: {cmd!r}")
|
||||
|
||||
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
|
||||
result = banner._check_via_local_git(repo_dir)
|
||||
|
||||
assert result == banner.UPDATE_AVAILABLE_NO_COUNT
|
||||
assert ["git", "fetch", "origin", "--quiet"] not in calls
|
||||
|
||||
|
||||
def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -1104,6 +1104,113 @@ class TestWebServerEndpoints:
|
|||
assert confirmed.status_code == 200
|
||||
assert confirmed.json()["ok"] is True
|
||||
|
||||
def test_model_set_normalizes_vendor_slug_for_native_provider(self, monkeypatch):
|
||||
"""'Use as → Main' with an OpenRouter slug + native provider must not
|
||||
persist the vendor-prefixed slug verbatim (it 400s against the native
|
||||
API and reads as "changing models does nothing")."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "anthropic",
|
||||
"model": "anthropic/claude-opus-4.6",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["provider"] == "anthropic"
|
||||
# Vendor prefix stripped + dots→hyphens for the native Anthropic API.
|
||||
assert data["model"] == "claude-opus-4-6"
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
assert cfg["model"]["provider"] == "anthropic"
|
||||
assert cfg["model"]["default"] == "claude-opus-4-6"
|
||||
|
||||
def test_model_set_maps_unknown_vendor_to_aggregator(self, monkeypatch):
|
||||
"""A bare vendor name from analytics rows (no billing_provider) is not
|
||||
a Hermes provider — keep the user's aggregator instead of writing a
|
||||
provider that can never resolve credentials."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
from hermes_cli.config import load_config, save_config
|
||||
cfg = load_config()
|
||||
cfg["model"] = {"provider": "openrouter", "default": "openai/gpt-5.5"}
|
||||
save_config(cfg)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "moonshotai", # vendor prefix, not a provider
|
||||
"model": "moonshotai/kimi-k2.6",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["provider"] == "openrouter"
|
||||
assert data["model"] == "moonshotai/kimi-k2.6"
|
||||
|
||||
def test_model_set_keeps_aggregator_slug_unchanged(self, monkeypatch):
|
||||
"""The happy path (picker → openrouter + vendor/model) is untouched."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["provider"] == "openrouter"
|
||||
assert data["model"] == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
def test_ops_import_passes_force_flag(self, tmp_path, monkeypatch):
|
||||
"""force=True must append --force so the spawned non-interactive
|
||||
`hermes import` doesn't auto-abort at the overwrite prompt."""
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
archive = tmp_path / "backup.zip"
|
||||
import zipfile
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr("config.yaml", "model: {}\n")
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_spawn(subcommand, name):
|
||||
captured["args"] = subcommand
|
||||
captured["name"] = name
|
||||
from types import SimpleNamespace as NS
|
||||
return NS(pid=12345)
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/ops/import", json={"archive": str(archive), "force": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert captured["args"] == ["import", str(archive), "--force"]
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/ops/import", json={"archive": str(archive)},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert captured["args"] == ["import", str(archive)]
|
||||
|
||||
|
||||
def test_reveal_env_var(self, tmp_path):
|
||||
"""POST /api/env/reveal should return the real unredacted value."""
|
||||
|
|
@ -4441,7 +4548,7 @@ class TestPtyWebSocket:
|
|||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
|
@ -4454,7 +4561,7 @@ class TestPtyWebSocket:
|
|||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
|
@ -4467,7 +4574,7 @@ class TestPtyWebSocket:
|
|||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (
|
||||
lambda resume=None, sidecar_url=None, profile=None: (
|
||||
["/bin/sh", "-c", "printf hermes-ws-ok"],
|
||||
None,
|
||||
None,
|
||||
|
|
@ -4497,7 +4604,7 @@ class TestPtyWebSocket:
|
|||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
with self.client.websocket_connect(self._url()) as conn:
|
||||
conn.send_bytes(b"round-trip-payload\n")
|
||||
|
|
@ -4530,7 +4637,7 @@ class TestPtyWebSocket:
|
|||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
# sleep gives the test time to push the resize before the child reads the ioctl.
|
||||
lambda resume=None, sidecar_url=None: (
|
||||
lambda resume=None, sidecar_url=None, profile=None: (
|
||||
[sys.executable, "-c", winsize_script],
|
||||
None,
|
||||
None,
|
||||
|
|
@ -4566,7 +4673,7 @@ class TestPtyWebSocket:
|
|||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
# Patch PtyBridge.spawn at the web_server module's binding.
|
||||
import hermes_cli.web_server as ws_mod
|
||||
|
|
@ -4581,7 +4688,7 @@ class TestPtyWebSocket:
|
|||
def test_resume_parameter_is_forwarded_to_argv(self, monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_resolve(resume=None, sidecar_url=None):
|
||||
def fake_resolve(resume=None, sidecar_url=None, profile=None):
|
||||
captured["resume"] = resume
|
||||
return (["/bin/sh", "-c", "printf resume-arg-ok"], None, None)
|
||||
|
||||
|
|
@ -4601,7 +4708,7 @@ class TestPtyWebSocket:
|
|||
same channel — which is how tool events reach the dashboard sidebar."""
|
||||
captured: dict = {}
|
||||
|
||||
def fake_resolve(resume=None, sidecar_url=None):
|
||||
def fake_resolve(resume=None, sidecar_url=None, profile=None):
|
||||
captured["sidecar_url"] = sidecar_url
|
||||
return (["/bin/sh", "-c", "printf sidecar-ok"], None, None)
|
||||
|
||||
|
|
|
|||
385
tests/hermes_cli/test_web_server_profile_unification.py
Normal file
385
tests/hermes_cli/test_web_server_profile_unification.py
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
"""Regression tests for the machine-dashboard multi-profile unification.
|
||||
|
||||
The dashboard is ONE machine-level management surface: config, env, MCP,
|
||||
model, and chat-PTY endpoints accept an optional ``profile`` so the global
|
||||
profile switcher can target any profile's HERMES_HOME. These tests pin:
|
||||
reads/writes land in the REQUESTED profile, the dashboard's own profile
|
||||
stays untouched, and the chat PTY env is scoped via HERMES_HOME.
|
||||
"""
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
||||
"""Isolated default home + one named profile, each with config + .env."""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli import profiles
|
||||
|
||||
default_home = get_hermes_home()
|
||||
profiles_root = default_home / "profiles"
|
||||
worker_home = profiles_root / "worker_beta"
|
||||
for home in (default_home, worker_home):
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
(worker_home / ".env").write_text("", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||
return {"default": default_home, "worker_beta": worker_home}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, isolated_profiles):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
def _cfg(home):
|
||||
return yaml.safe_load((home / "config.yaml").read_text()) or {}
|
||||
|
||||
|
||||
class TestProfileScopedConfig:
|
||||
def test_config_put_lands_in_target_profile_only(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/config",
|
||||
json={"config": {"timezone": "Mars/Olympus"}, "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Mars/Olympus"
|
||||
assert _cfg(isolated_profiles["default"]).get("timezone") != "Mars/Olympus"
|
||||
|
||||
def test_config_get_reads_target_profile(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"timezone: Venus/Cloud\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.get("/api/config", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("timezone") == "Venus/Cloud"
|
||||
# Unscoped read sees the dashboard's own config.
|
||||
resp = client.get("/api/config")
|
||||
assert resp.json().get("timezone") != "Venus/Cloud"
|
||||
|
||||
def test_config_query_param_equivalent_to_body(self, client, isolated_profiles):
|
||||
"""The SPA's fetchJSON injects ?profile= — must scope like body.profile."""
|
||||
resp = client.put(
|
||||
"/api/config?profile=worker_beta",
|
||||
json={"config": {"timezone": "Pluto/Far"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Pluto/Far"
|
||||
assert _cfg(isolated_profiles["default"]).get("timezone") != "Pluto/Far"
|
||||
|
||||
def test_config_raw_round_trip_scoped(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/config/raw",
|
||||
json={"yaml_text": "timezone: Io/Volcano\n", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = client.get("/api/config/raw", params={"profile": "worker_beta"})
|
||||
assert "Io/Volcano" in resp.json()["yaml"]
|
||||
resp = client.get("/api/config/raw")
|
||||
assert "Io/Volcano" not in resp.json()["yaml"]
|
||||
|
||||
def test_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/config", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestProfileScopedEnv:
|
||||
def test_env_set_lands_in_target_profile_only(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/env",
|
||||
json={"key": "FAL_KEY", "value": "test-fal-123", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
assert "test-fal-123" in worker_env
|
||||
default_env_path = isolated_profiles["default"] / ".env"
|
||||
if default_env_path.exists():
|
||||
assert "test-fal-123" not in default_env_path.read_text()
|
||||
|
||||
def test_env_list_reads_target_profile(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / ".env").write_text(
|
||||
"FAL_KEY=worker-only-value\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.get("/api/env", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["FAL_KEY"]["is_set"] is True
|
||||
resp = client.get("/api/env")
|
||||
assert resp.json()["FAL_KEY"]["is_set"] is False
|
||||
|
||||
def test_env_delete_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / ".env").write_text(
|
||||
"FAL_KEY=doomed\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
"/api/env",
|
||||
json={"key": "FAL_KEY", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "doomed" not in (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
|
||||
|
||||
class TestProfileScopedMcp:
|
||||
def test_mcp_add_and_list_scoped(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/mcp/servers",
|
||||
json={"name": "scoped-srv", "url": "http://localhost:1234/sse",
|
||||
"profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
assert "scoped-srv" in worker_cfg.get("mcp_servers", {})
|
||||
assert "scoped-srv" not in _cfg(isolated_profiles["default"]).get("mcp_servers", {})
|
||||
|
||||
listing = client.get("/api/mcp/servers", params={"profile": "worker_beta"}).json()
|
||||
assert any(s["name"] == "scoped-srv" for s in listing["servers"])
|
||||
listing = client.get("/api/mcp/servers").json()
|
||||
assert not any(s["name"] == "scoped-srv" for s in listing["servers"])
|
||||
|
||||
def test_mcp_enabled_toggle_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n srv1:\n url: http://x/sse\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.put(
|
||||
"/api/mcp/servers/srv1/enabled",
|
||||
json={"enabled": False, "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
assert worker_cfg["mcp_servers"]["srv1"]["enabled"] is False
|
||||
|
||||
def test_mcp_probe_runs_inside_profile_scope(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""The test-server probe must execute with the selected profile's
|
||||
scope active so env-placeholder expansion reads the profile's .env,
|
||||
matching the config the server was saved into."""
|
||||
import hermes_cli.mcp_config as mcp_config
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n probe-srv:\n url: http://x/sse\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
seen = {}
|
||||
|
||||
def fake_probe(name, config, connect_timeout=30):
|
||||
seen["home"] = str(get_hermes_home())
|
||||
return [("tool-a", "desc")]
|
||||
|
||||
monkeypatch.setattr(mcp_config, "_probe_single_server", fake_probe)
|
||||
resp = client.post(
|
||||
"/api/mcp/servers/probe-srv/test", params={"profile": "worker_beta"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
assert seen["home"] == str(isolated_profiles["worker_beta"])
|
||||
|
||||
def test_mcp_remove_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n srv2:\n url: http://x/sse\n", encoding="utf-8"
|
||||
)
|
||||
# Removing from the DASHBOARD's profile must 404 (srv2 lives in worker).
|
||||
resp = client.delete("/api/mcp/servers/srv2")
|
||||
assert resp.status_code == 404
|
||||
resp = client.delete("/api/mcp/servers/srv2", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
assert "srv2" not in _cfg(isolated_profiles["worker_beta"]).get("mcp_servers", {})
|
||||
|
||||
|
||||
class TestProfileScopedModel:
|
||||
def test_model_set_main_scoped(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "openrouter",
|
||||
"model": "test/model-1",
|
||||
"confirm_expensive_model": True,
|
||||
"profile": "worker_beta",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
model_cfg = worker_cfg.get("model", {})
|
||||
assert isinstance(model_cfg, dict)
|
||||
assert model_cfg.get("provider") == "openrouter"
|
||||
default_model = _cfg(isolated_profiles["default"]).get("model", {})
|
||||
if isinstance(default_model, dict):
|
||||
assert default_model.get("default") != "test/model-1"
|
||||
|
||||
def test_auxiliary_read_scoped_matches_write_target(
|
||||
self, client, isolated_profiles
|
||||
):
|
||||
"""Reads and writes must scope symmetrically: an aux pin written to
|
||||
the worker profile must show up ONLY in the worker-scoped read.
|
||||
(Regression: /api/model/auxiliary used to read unscoped while
|
||||
/api/model/set wrote scoped — the Models page displayed the
|
||||
dashboard profile's pins while editing the selected profile's.)"""
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"auxiliary:\n vision:\n provider: openrouter\n"
|
||||
" model: worker/vision-pin\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
resp = client.get("/api/model/auxiliary", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision")
|
||||
assert vision["model"] == "worker/vision-pin"
|
||||
|
||||
# Unscoped read = the dashboard's own profile, which has no pin.
|
||||
resp = client.get("/api/model/auxiliary")
|
||||
assert resp.status_code == 200
|
||||
vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision")
|
||||
assert vision["model"] != "worker/vision-pin"
|
||||
|
||||
def test_auxiliary_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/model/auxiliary", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_model_options_scoped_to_profile(self, client, isolated_profiles):
|
||||
"""The Models picker must read the SAME profile model/set writes —
|
||||
current model/provider in the payload come from the scoped config."""
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"model:\n provider: openrouter\n default: worker/current-pin\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
resp = client.get("/api/model/options", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# The payload carries the current selection somewhere stable; assert
|
||||
# the worker pin appears in the scoped response and not the unscoped.
|
||||
assert "worker/current-pin" in resp.text
|
||||
resp = client.get("/api/model/options")
|
||||
assert resp.status_code == 200
|
||||
assert "worker/current-pin" not in resp.text
|
||||
assert isinstance(body, dict)
|
||||
|
||||
def test_model_options_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/model/options", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_model_info_unknown_profile_404(self, client, isolated_profiles):
|
||||
"""Regression: the broad except used to convert the 404 into a 200
|
||||
with empty model info ("no model set" — silently wrong)."""
|
||||
resp = client.get("/api/model/info", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_mcp_catalog_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/mcp/catalog", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestProfileScopedPostSetup:
|
||||
def test_post_setup_spawns_with_profile_flag(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""Post-setup runs in a -p scoped subprocess so hooks that read
|
||||
config / write per-profile state see the same HERMES_HOME the rest
|
||||
of the drawer's writes targeted."""
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 777
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"_spawn_hermes_action",
|
||||
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config.valid_post_setup_keys",
|
||||
lambda: {"agent_browser"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/tools/toolsets/browser/post-setup",
|
||||
json={"key": "agent_browser", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert calls == [
|
||||
["-p", "worker_beta", "tools", "post-setup", "agent_browser"]
|
||||
]
|
||||
|
||||
def test_post_setup_without_profile_keeps_legacy_argv(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 777
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"_spawn_hermes_action",
|
||||
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config.valid_post_setup_keys",
|
||||
lambda: {"agent_browser"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/tools/toolsets/browser/post-setup",
|
||||
json={"key": "agent_browser"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert calls == [["tools", "post-setup", "agent_browser"]]
|
||||
|
||||
|
||||
class TestProfileScopedChatPty:
|
||||
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
argv, cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
|
||||
assert env is not None
|
||||
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
|
||||
# Scoped chat must NOT attach to the dashboard's in-memory gateway.
|
||||
assert "HERMES_TUI_GATEWAY_URL" not in env
|
||||
|
||||
def test_chat_argv_unscoped_keeps_legacy_env(self, isolated_profiles, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
argv, cwd, env = web_server._resolve_chat_argv()
|
||||
assert env is not None
|
||||
assert env.get("HERMES_HOME") != str(isolated_profiles["worker_beta"])
|
||||
|
||||
def test_chat_argv_unknown_profile_raises(self, isolated_profiles, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
# Reuse the HTTPException class web_server itself raises — avoids a
|
||||
# direct fastapi import (unresolvable in the ty lint environment).
|
||||
with pytest.raises(web_server.HTTPException) as exc:
|
||||
web_server._resolve_chat_argv(profile="ghost")
|
||||
assert exc.value.status_code == 404
|
||||
259
tests/hermes_cli/test_web_server_skill_editor.py
Normal file
259
tests/hermes_cli/test_web_server_skill_editor.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""Tests for the dashboard skill editor endpoints and cron skill attachment.
|
||||
|
||||
The Skills page can now create/edit custom skills (SKILL.md) and the Cron
|
||||
page can attach skills to jobs — closing the "SSH + nano is the only way"
|
||||
gap for headless/VPS users. These tests pin:
|
||||
|
||||
- GET /api/skills/content returns raw SKILL.md (and profile-scopes).
|
||||
- POST /api/skills creates a skill through the same validated write path
|
||||
as the agent's ``skill_manage`` tool (frontmatter validation enforced).
|
||||
- PUT /api/skills/content rewrites an existing SKILL.md (404 on unknown).
|
||||
- POST /api/cron/jobs accepts ``skills`` and persists it on the job;
|
||||
PUT /api/cron/jobs/{id} can update the list.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
SKILL_MD = """---
|
||||
name: {name}
|
||||
description: a test skill
|
||||
---
|
||||
|
||||
# {name}
|
||||
|
||||
Do the thing.
|
||||
"""
|
||||
|
||||
|
||||
def _write_skill(skills_dir, name):
|
||||
d = skills_dir / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SKILL.md").write_text(SKILL_MD.format(name=name), encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
||||
"""Isolated default home + one named profile, each with its own skills."""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli import profiles
|
||||
|
||||
default_home = get_hermes_home()
|
||||
profiles_root = default_home / "profiles"
|
||||
worker_home = profiles_root / "worker_alpha"
|
||||
for home in (default_home, worker_home):
|
||||
(home / "skills").mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
|
||||
_write_skill(default_home / "skills", "dashboard-skill")
|
||||
_write_skill(worker_home / "skills", "worker-skill")
|
||||
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||
return {"default": default_home, "worker_alpha": worker_home}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, isolated_profiles):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
class TestSkillContent:
|
||||
def test_get_content_returns_raw_skill_md(self, client, isolated_profiles):
|
||||
resp = client.get("/api/skills/content", params={"name": "dashboard-skill"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "dashboard-skill"
|
||||
assert data["content"].startswith("---")
|
||||
assert "Do the thing." in data["content"]
|
||||
|
||||
def test_get_content_scopes_to_profile(self, client, isolated_profiles):
|
||||
resp = client.get(
|
||||
"/api/skills/content",
|
||||
params={"name": "worker-skill", "profile": "worker_alpha"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# ...and the worker skill is invisible without the profile param.
|
||||
resp = client.get("/api/skills/content", params={"name": "worker-skill"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_content_unknown_skill_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/skills/content", params={"name": "nope"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestSkillCreate:
|
||||
def test_create_writes_skill_md(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={"name": "my-new-skill", "content": SKILL_MD.format(name="my-new-skill")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
skill_md = isolated_profiles["default"] / "skills" / "my-new-skill" / "SKILL.md"
|
||||
assert skill_md.exists()
|
||||
assert "Do the thing." in skill_md.read_text(encoding="utf-8")
|
||||
|
||||
def test_create_with_category(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={
|
||||
"name": "cat-skill",
|
||||
"category": "devops",
|
||||
"content": SKILL_MD.format(name="cat-skill"),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert (
|
||||
isolated_profiles["default"] / "skills" / "devops" / "cat-skill" / "SKILL.md"
|
||||
).exists()
|
||||
|
||||
def test_create_scopes_to_profile(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={
|
||||
"name": "worker-new",
|
||||
"content": SKILL_MD.format(name="worker-new"),
|
||||
"profile": "worker_alpha",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert (
|
||||
isolated_profiles["worker_alpha"] / "skills" / "worker-new" / "SKILL.md"
|
||||
).exists()
|
||||
# Dashboard's own skills dir stays clean.
|
||||
assert not (
|
||||
isolated_profiles["default"] / "skills" / "worker-new"
|
||||
).exists()
|
||||
|
||||
def test_create_rejects_missing_frontmatter(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={"name": "bad-skill", "content": "no frontmatter here"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "frontmatter" in resp.json()["detail"].lower()
|
||||
assert not (isolated_profiles["default"] / "skills" / "bad-skill").exists()
|
||||
|
||||
def test_create_rejects_duplicate_name(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={
|
||||
"name": "dashboard-skill",
|
||||
"content": SKILL_MD.format(name="dashboard-skill"),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "already exists" in resp.json()["detail"]
|
||||
|
||||
def test_create_rejects_invalid_name(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={"name": "../escape", "content": SKILL_MD.format(name="x")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestSkillUpdate:
|
||||
def test_update_rewrites_skill_md(self, client, isolated_profiles):
|
||||
new_content = SKILL_MD.format(name="dashboard-skill").replace(
|
||||
"Do the thing.", "Do the NEW thing."
|
||||
)
|
||||
resp = client.put(
|
||||
"/api/skills/content",
|
||||
json={"name": "dashboard-skill", "content": new_content},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
skill_md = (
|
||||
isolated_profiles["default"] / "skills" / "dashboard-skill" / "SKILL.md"
|
||||
)
|
||||
assert "Do the NEW thing." in skill_md.read_text(encoding="utf-8")
|
||||
|
||||
def test_update_unknown_skill_404(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/skills/content",
|
||||
json={"name": "nope", "content": SKILL_MD.format(name="nope")},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_invalid_frontmatter_400(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/skills/content",
|
||||
json={"name": "dashboard-skill", "content": "broken"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestEditorEndpointsAuth:
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,kwargs",
|
||||
[
|
||||
("get", "/api/skills/content?name=dashboard-skill", {}),
|
||||
("post", "/api/skills", {"json": {"name": "x", "content": "y"}}),
|
||||
("put", "/api/skills/content", {"json": {"name": "x", "content": "y"}}),
|
||||
],
|
||||
)
|
||||
def test_endpoints_401_without_token(
|
||||
self, client, isolated_profiles, method, path, kwargs
|
||||
):
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME
|
||||
|
||||
client.headers.pop(_SESSION_HEADER_NAME, None)
|
||||
resp = getattr(client, method)(path, **kwargs)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestCronJobSkills:
|
||||
def test_create_job_with_skills(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/cron/jobs",
|
||||
json={
|
||||
"prompt": "do work",
|
||||
"schedule": "every 1h",
|
||||
"name": "skilled-job",
|
||||
"skills": ["dashboard-skill"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
job = resp.json()
|
||||
assert job["skills"] == ["dashboard-skill"]
|
||||
|
||||
# Round-trip: the list endpoint carries the skills field too.
|
||||
listed = client.get("/api/cron/jobs", params={"profile": "default"}).json()
|
||||
match = [j for j in listed if j["id"] == job["id"]]
|
||||
assert match and match[0]["skills"] == ["dashboard-skill"]
|
||||
|
||||
def test_update_job_skills(self, client, isolated_profiles):
|
||||
job = client.post(
|
||||
"/api/cron/jobs",
|
||||
json={"prompt": "do work", "schedule": "every 1h"},
|
||||
).json()
|
||||
assert job.get("skills") in (None, [])
|
||||
|
||||
resp = client.put(
|
||||
f"/api/cron/jobs/{job['id']}",
|
||||
json={"updates": {"skills": ["dashboard-skill", "worker-skill"]}},
|
||||
params={"profile": "default"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["skills"] == ["dashboard-skill", "worker-skill"]
|
||||
|
||||
# Clearing works too.
|
||||
resp = client.put(
|
||||
f"/api/cron/jobs/{job['id']}",
|
||||
json={"updates": {"skills": []}},
|
||||
params={"profile": "default"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["skills"] == []
|
||||
210
tests/hermes_cli/test_web_server_skills_profiles.py
Normal file
210
tests/hermes_cli/test_web_server_skills_profiles.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""Regression tests for dashboard profile-scoped skills/toolsets management.
|
||||
|
||||
"Set as active" on the Profiles page only flips the sticky ``active_profile``
|
||||
file (future CLI/gateway runs) — it never retargets the running dashboard
|
||||
process. Before the ``profile`` parameter existed, toggling a skill after
|
||||
"activating" a profile silently wrote into the dashboard's own config.
|
||||
These tests pin the new behavior: reads and writes land in the REQUESTED
|
||||
profile's HERMES_HOME, and the dashboard's own profile stays untouched.
|
||||
"""
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
def _write_skill(skills_dir, name, description="test skill"):
|
||||
d = skills_dir / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
||||
"""Isolated default home + one named profile, each with its own skills."""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli import profiles
|
||||
|
||||
default_home = get_hermes_home()
|
||||
profiles_root = default_home / "profiles"
|
||||
worker_home = profiles_root / "worker_alpha"
|
||||
for home in (default_home, worker_home):
|
||||
(home / "skills").mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
|
||||
_write_skill(default_home / "skills", "dashboard-skill")
|
||||
_write_skill(worker_home / "skills", "worker-skill")
|
||||
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||
return {"default": default_home, "worker_alpha": worker_home}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, isolated_profiles):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
def _load_cfg(home):
|
||||
return yaml.safe_load((home / "config.yaml").read_text()) or {}
|
||||
|
||||
|
||||
class TestProfileScopedSkills:
|
||||
def test_skills_list_scopes_to_requested_profile(self, client, isolated_profiles):
|
||||
resp = client.get("/api/skills", params={"profile": "worker_alpha"})
|
||||
assert resp.status_code == 200
|
||||
names = {s["name"] for s in resp.json()}
|
||||
assert "worker-skill" in names
|
||||
assert "dashboard-skill" not in names
|
||||
|
||||
def test_skills_list_without_profile_uses_dashboard_home(
|
||||
self, client, isolated_profiles
|
||||
):
|
||||
resp = client.get("/api/skills")
|
||||
assert resp.status_code == 200
|
||||
names = {s["name"] for s in resp.json()}
|
||||
assert "dashboard-skill" in names
|
||||
assert "worker-skill" not in names
|
||||
|
||||
def test_toggle_writes_into_target_profile_only(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/skills/toggle",
|
||||
json={"name": "worker-skill", "enabled": False, "profile": "worker_alpha"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "name": "worker-skill", "enabled": False}
|
||||
|
||||
worker_cfg = _load_cfg(isolated_profiles["worker_alpha"])
|
||||
assert "worker-skill" in worker_cfg.get("skills", {}).get("disabled", [])
|
||||
# The dashboard's own config must stay untouched — this was the bug.
|
||||
default_cfg = _load_cfg(isolated_profiles["default"])
|
||||
assert "worker-skill" not in default_cfg.get("skills", {}).get("disabled", [])
|
||||
|
||||
def test_toggle_reenable_round_trip(self, client, isolated_profiles):
|
||||
for enabled in (False, True):
|
||||
client.put(
|
||||
"/api/skills/toggle",
|
||||
json={
|
||||
"name": "worker-skill",
|
||||
"enabled": enabled,
|
||||
"profile": "worker_alpha",
|
||||
},
|
||||
)
|
||||
worker_cfg = _load_cfg(isolated_profiles["worker_alpha"])
|
||||
assert "worker-skill" not in worker_cfg.get("skills", {}).get("disabled", [])
|
||||
|
||||
def test_unknown_profile_returns_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/skills", params={"profile": "no_such_profile"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_invalid_profile_name_returns_400(self, client, isolated_profiles):
|
||||
resp = client.get("/api/skills", params={"profile": "Bad Name!"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_scope_restores_module_globals(self, client, isolated_profiles):
|
||||
"""The SKILLS_DIR swap is per-request; the module global must be
|
||||
restored even after a scoped call (cron-style locked swap)."""
|
||||
import tools.skills_tool as skills_tool
|
||||
|
||||
before = skills_tool.SKILLS_DIR
|
||||
client.get("/api/skills", params={"profile": "worker_alpha"})
|
||||
assert skills_tool.SKILLS_DIR == before
|
||||
|
||||
|
||||
class TestProfileScopedToolsets:
|
||||
def test_toolset_toggle_scopes_to_profile(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/tools/toolsets/x_search",
|
||||
json={"enabled": True, "profile": "worker_alpha"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
worker_cfg = _load_cfg(isolated_profiles["worker_alpha"])
|
||||
assert "x_search" in worker_cfg.get("platform_toolsets", {}).get("cli", [])
|
||||
default_cfg = _load_cfg(isolated_profiles["default"])
|
||||
assert "x_search" not in default_cfg.get("platform_toolsets", {}).get("cli", [])
|
||||
|
||||
listing = client.get(
|
||||
"/api/tools/toolsets", params={"profile": "worker_alpha"}
|
||||
).json()
|
||||
assert {t["name"]: t for t in listing}["x_search"]["enabled"] is True
|
||||
# Unscoped listing reflects the dashboard's own (untouched) config.
|
||||
listing = client.get("/api/tools/toolsets").json()
|
||||
assert {t["name"]: t for t in listing}["x_search"]["enabled"] is False
|
||||
|
||||
def test_toolset_toggle_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/tools/toolsets/x_search",
|
||||
json={"enabled": True, "profile": "ghost"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestProfileScopedHubActions:
|
||||
def test_hub_install_spawns_with_profile_flag(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""Hub installs must go through a fresh ``hermes -p <profile>``
|
||||
subprocess — the in-process scope can't reach skills_hub's
|
||||
import-time SKILLS_DIR binding."""
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4242
|
||||
|
||||
def _fake_spawn(subcommand, name):
|
||||
calls.append((list(subcommand), name))
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(web_server, "_spawn_hermes_action", _fake_spawn)
|
||||
resp = client.post(
|
||||
"/api/skills/hub/install",
|
||||
json={"identifier": "official/demo", "profile": "worker_alpha"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert calls == [
|
||||
(["-p", "worker_alpha", "skills", "install", "official/demo"], "skills-install")
|
||||
]
|
||||
|
||||
def test_hub_install_without_profile_keeps_legacy_argv(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4242
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"_spawn_hermes_action",
|
||||
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/skills/hub/install", json={"identifier": "official/demo"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert calls == [["skills", "install", "official/demo"]]
|
||||
|
||||
def test_hub_install_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills/hub/install",
|
||||
json={"identifier": "official/demo", "profile": "ghost"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
|
@ -142,6 +142,11 @@ class TestBuildWebUISkipsWhenFresh:
|
|||
|
||||
def test_npm_install_uses_workspace_web_scope(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
# Real workspace checkout: the single lockfile lives at the root, so
|
||||
# _workspace_root(web_dir) resolves to the parent and --workspace web
|
||||
# scopes the install. (Without a root lockfile, web_dir IS the root and
|
||||
# --workspace would be dropped — see test below and #42973.)
|
||||
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
|
|
@ -153,6 +158,36 @@ class TestBuildWebUISkipsWhenFresh:
|
|||
assert "--workspace" in install_cmd
|
||||
assert install_cmd[install_cmd.index("--workspace") + 1] == "web"
|
||||
|
||||
def test_web_install_omits_workspace_when_web_has_own_lockfile(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""web/ with its own lockfile => _workspace_root returns web_dir, so
|
||||
--workspace web would fail (npm can't find that workspace from inside
|
||||
web/). The flag must be dropped and the install run plainly from web_dir.
|
||||
Symmetric to the TUI fix in test_tui_npm_install.py. See #42973.
|
||||
|
||||
With web's own lockfile present at cwd, _run_npm_install_deterministic
|
||||
uses ``npm ci`` (not ``npm install``).
|
||||
"""
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
|
||||
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \
|
||||
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp):
|
||||
result = _build_web_ui(web_dir)
|
||||
|
||||
assert result is True
|
||||
args, kwargs = mock_run.call_args
|
||||
assert "--workspace" not in args[0]
|
||||
assert args[0] == ["/usr/bin/npm", "ci", "--silent"]
|
||||
assert kwargs["cwd"] == web_dir
|
||||
|
||||
def test_web_build_uses_idle_timeout_helper(self, tmp_path):
|
||||
"""npm run build now goes through _run_with_idle_timeout (issue #33788).
|
||||
|
||||
|
|
|
|||
51
tests/hermes_state/test_session_archiving.py
Normal file
51
tests/hermes_state/test_session_archiving.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
database = SessionDB(tmp_path / "state.db")
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def _compression_pair(db: SessionDB):
|
||||
base = time.time() - 100
|
||||
db.create_session("root", source="cli")
|
||||
db.create_session("tip", source="cli", parent_session_id="root")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', message_count = 1 WHERE id = 'root'",
|
||||
(base, base + 10),
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ?, message_count = 1 WHERE id = 'tip'",
|
||||
(base + 20,),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_archiving_compression_tip_archives_projected_root(db):
|
||||
_compression_pair(db)
|
||||
|
||||
assert db.set_session_archived("tip", True) is True
|
||||
|
||||
assert db.get_session("root")["archived"] == 1
|
||||
assert db.get_session("tip")["archived"] == 1
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == []
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True, archived_only=True)] == ["tip"]
|
||||
|
||||
|
||||
def test_unarchiving_compression_tip_unarchives_projected_root(db):
|
||||
_compression_pair(db)
|
||||
db.set_session_archived("tip", True)
|
||||
|
||||
assert db.set_session_archived("tip", False) is True
|
||||
|
||||
assert db.get_session("root")["archived"] == 0
|
||||
assert db.get_session("tip")["archived"] == 0
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == ["tip"]
|
||||
383
tests/plugins/web/test_parallel_keyless_mcp.py
Normal file
383
tests/plugins/web/test_parallel_keyless_mcp.py
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
"""Keyless Parallel search via the free hosted Search MCP.
|
||||
|
||||
Covers the transport added in ``plugins/web/parallel/provider.py`` that lets
|
||||
``web_search`` work with no ``PARALLEL_API_KEY``:
|
||||
|
||||
- ``_mcp_headers`` — Bearer attached only when a key is held
|
||||
- ``_decode_mcp_envelope`` — plain-JSON and SSE (``data:``) response bodies
|
||||
- ``_mcp_payload`` — structuredContent preferred, text-block JSON fallback, errors
|
||||
- ``_mcp_web_search`` — full handshake (mocked transport) → standard search shape
|
||||
- ``ParallelWebSearchProvider.search`` — keyless path routes to the MCP
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.web.parallel.provider as pp
|
||||
|
||||
|
||||
# ─── _mcp_headers ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestMcpHeaders:
|
||||
def test_anonymous_has_no_authorization(self):
|
||||
h = pp._mcp_headers(session_id=None, api_key=None)
|
||||
assert "Authorization" not in h
|
||||
assert h["Accept"] == "application/json, text/event-stream"
|
||||
assert "Mcp-Session-Id" not in h
|
||||
|
||||
def test_user_agent_is_generic_not_hermes(self):
|
||||
# Telemetry policy: no third-party usage attribution without opt-in.
|
||||
# The UA must be set (not python-httpx default) but must not name
|
||||
# hermes, on both the anonymous and keyed paths.
|
||||
for ua in (
|
||||
pp._mcp_headers(session_id=None, api_key=None)["User-Agent"],
|
||||
pp._mcp_headers(session_id="sid", api_key="pk-live")["User-Agent"],
|
||||
):
|
||||
assert ua == f"{pp._MCP_CLIENT_NAME}/{pp._MCP_CLIENT_VERSION}"
|
||||
assert "hermes" not in ua.lower()
|
||||
|
||||
def test_session_id_and_bearer_when_present(self):
|
||||
h = pp._mcp_headers(session_id="sid-123", api_key="pk-live")
|
||||
assert h["Mcp-Session-Id"] == "sid-123"
|
||||
assert h["Authorization"] == "Bearer pk-live"
|
||||
|
||||
|
||||
# ─── SSE / JSON-RPC parsing ──────────────────────────────────────────────────
|
||||
|
||||
class TestMcpResponseParsing:
|
||||
def test_plain_json_matched_by_id(self):
|
||||
body = '{"jsonrpc":"2.0","id":"abc","result":{"ok":true}}'
|
||||
assert pp._mcp_response_envelope(body, "abc")["result"]["ok"] is True
|
||||
|
||||
def test_sse_selects_response_for_request_id_skipping_notifications(self):
|
||||
# A progress notification (no id) precedes the real result; an unrelated
|
||||
# response id is also present. We must pick the one matching our id.
|
||||
body = (
|
||||
'event: message\ndata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"p":1}}\n\n'
|
||||
'event: message\ndata: {"jsonrpc":"2.0","id":"other","result":{"ok":false}}\n\n'
|
||||
'event: message\ndata: {"jsonrpc":"2.0","id":"req-1","result":{"ok":true}}\n\n'
|
||||
)
|
||||
env = pp._mcp_response_envelope(body, "req-1")
|
||||
assert env["result"]["ok"] is True
|
||||
|
||||
def test_sse_multiline_data_concatenated(self):
|
||||
body = 'data: {"jsonrpc":"2.0","id":"x",\ndata: "result":{"n":42}}\n\n'
|
||||
assert pp._mcp_response_envelope(body, "x")["result"]["n"] == 42
|
||||
|
||||
def test_falls_back_to_last_result_when_id_absent(self):
|
||||
body = '{"jsonrpc":"2.0","id":"server-chose","result":{"ok":true}}'
|
||||
# request id doesn't match, but there's a single result → use it
|
||||
assert pp._mcp_response_envelope(body, "mismatch")["result"]["ok"] is True
|
||||
|
||||
def test_empty_body(self):
|
||||
assert pp._mcp_response_envelope("", "x") == {}
|
||||
assert pp._mcp_response_envelope(" ", "x") == {}
|
||||
|
||||
def test_batched_json_array_flattened(self):
|
||||
# Streamable HTTP may batch messages into a JSON array.
|
||||
body = ('[{"jsonrpc":"2.0","method":"notifications/progress"},'
|
||||
'{"jsonrpc":"2.0","id":"req-9","result":{"ok":true}}]')
|
||||
assert pp._mcp_response_envelope(body, "req-9")["result"]["ok"] is True
|
||||
|
||||
def test_batched_sse_data_array_flattened(self):
|
||||
body = 'data: [{"jsonrpc":"2.0","id":"a","result":{"n":1}}]\n\n'
|
||||
assert pp._mcp_response_envelope(body, "a")["result"]["n"] == 1
|
||||
|
||||
|
||||
# ─── _mcp_payload ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestMcpPayload:
|
||||
def test_prefers_structured_content(self):
|
||||
env = {"result": {"structuredContent": {"results": [{"url": "u"}]},
|
||||
"content": [{"type": "text", "text": "ignored"}]}}
|
||||
assert pp._mcp_payload(env) == {"results": [{"url": "u"}]}
|
||||
|
||||
def test_parses_text_block_json(self):
|
||||
inner = {"search_id": "s1", "results": [{"url": "u", "title": "t"}]}
|
||||
env = {"result": {"content": [{"type": "text", "text": json.dumps(inner)}]}}
|
||||
assert pp._mcp_payload(env)["search_id"] == "s1"
|
||||
|
||||
def test_raises_on_jsonrpc_error(self):
|
||||
with pytest.raises(RuntimeError, match="Parallel MCP error"):
|
||||
pp._mcp_payload({"error": {"code": -32000, "message": "boom"}})
|
||||
|
||||
def test_raises_on_tool_iserror(self):
|
||||
with pytest.raises(RuntimeError, match="Parallel MCP tool error"):
|
||||
pp._mcp_payload({"result": {"isError": True, "content": []}})
|
||||
|
||||
|
||||
# ─── _mcp_web_search (mocked transport) ──────────────────────────────────────
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, *, text="", headers=None):
|
||||
self.text = text
|
||||
self.headers = headers or {}
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Stands in for httpx.Client: replays init → ack → tools/call."""
|
||||
|
||||
def __init__(self, search_payload, init_session_id="server-sid"):
|
||||
self._search_payload = search_payload
|
||||
self._init_session_id = init_session_id
|
||||
self.calls = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def post(self, url, headers=None, json=None):
|
||||
self.calls.append({"headers": headers, "json": json})
|
||||
req = json or {}
|
||||
method = req.get("method")
|
||||
req_id = req.get("id")
|
||||
if method == "initialize":
|
||||
# Echo the request id, as the real server does.
|
||||
return _FakeResponse(
|
||||
text=json_dumps({"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {"protocolVersion": "2099-01-01"}}),
|
||||
headers=(
|
||||
{"mcp-session-id": self._init_session_id}
|
||||
if self._init_session_id is not None
|
||||
else {}
|
||||
),
|
||||
)
|
||||
if method == "notifications/initialized":
|
||||
return _FakeResponse(text="")
|
||||
# tools/call
|
||||
envelope = {"jsonrpc": "2.0", "id": req_id, "result": {
|
||||
"content": [{"type": "text", "text": json_dumps(self._search_payload)}],
|
||||
}}
|
||||
return _FakeResponse(text=json_dumps(envelope))
|
||||
|
||||
|
||||
def json_dumps(obj):
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
class TestMcpWebSearch:
|
||||
def _payload(self, n):
|
||||
return {"search_id": "s", "results": [
|
||||
{"url": f"https://ex/{i}", "title": f"t{i}",
|
||||
"excerpts": [f"a{i}", f"b{i}"]}
|
||||
for i in range(n)
|
||||
]}
|
||||
|
||||
def test_returns_standard_shape_and_handshake(self):
|
||||
fake = _FakeClient(self._payload(3))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_search("hello", limit=5, api_key=None)
|
||||
|
||||
assert out["success"] is True
|
||||
# Free-tier results credit Parallel.
|
||||
assert "Parallel" in out["attribution"]
|
||||
web = out["data"]["web"]
|
||||
assert [r["position"] for r in web] == [1, 2, 3]
|
||||
assert web[0]["url"] == "https://ex/0"
|
||||
assert web[0]["description"] == "a0 b0" # excerpts joined
|
||||
# handshake order
|
||||
methods = [c["json"].get("method") for c in fake.calls]
|
||||
assert methods == ["initialize", "notifications/initialized", "tools/call"]
|
||||
# session id from the initialize response header is reused
|
||||
assert fake.calls[-1]["headers"]["Mcp-Session-Id"] == "server-sid"
|
||||
|
||||
def test_stateless_server_no_session_header_not_invented(self):
|
||||
# A stateless Streamable-HTTP server may omit mcp-session-id on
|
||||
# initialize; we must NOT invent one (sending an unissued session id can
|
||||
# get follow-up requests rejected). The follow-ups carry no header.
|
||||
fake = _FakeClient(self._payload(1), init_session_id=None)
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_search("hello", limit=5, api_key=None)
|
||||
assert out["success"] is True
|
||||
follow_ups = [c for c in fake.calls if c["json"].get("method") != "initialize"]
|
||||
assert follow_ups, "expected notifications/initialized + tools/call"
|
||||
assert all("Mcp-Session-Id" not in c["headers"] for c in follow_ups)
|
||||
# anonymous → no Authorization on any call
|
||||
assert all("Authorization" not in c["headers"] for c in fake.calls)
|
||||
# tools/call mirrors query into objective + search_queries
|
||||
args = fake.calls[-1]["json"]["params"]["arguments"]
|
||||
assert args["objective"] == "hello"
|
||||
assert args["search_queries"] == ["hello"]
|
||||
|
||||
def test_limit_is_applied_client_side(self):
|
||||
fake = _FakeClient(self._payload(10))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_search("q", limit=2, api_key=None)
|
||||
assert len(out["data"]["web"]) == 2
|
||||
|
||||
def test_bearer_attached_when_key_present(self):
|
||||
fake = _FakeClient(self._payload(1))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
pp._mcp_web_search("q", limit=1, api_key="pk-live")
|
||||
assert all(c["headers"]["Authorization"] == "Bearer pk-live" for c in fake.calls)
|
||||
|
||||
def test_negotiated_protocol_version_echoed_post_init(self):
|
||||
fake = _FakeClient(self._payload(1))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
pp._mcp_web_search("q", limit=1, api_key=None)
|
||||
# initialize request doesn't carry the (not-yet-negotiated) version...
|
||||
assert "MCP-Protocol-Version" not in fake.calls[0]["headers"]
|
||||
# ...but notifications/initialized and tools/call echo the negotiated one.
|
||||
assert fake.calls[1]["headers"]["MCP-Protocol-Version"] == "2099-01-01"
|
||||
assert fake.calls[-1]["headers"]["MCP-Protocol-Version"] == "2099-01-01"
|
||||
|
||||
|
||||
# ─── provider.search keyless routing ─────────────────────────────────────────
|
||||
|
||||
class TestProviderKeylessSearch:
|
||||
def test_search_without_key_uses_mcp(self, monkeypatch):
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
captured = {}
|
||||
|
||||
def _fake(query, limit, api_key):
|
||||
captured.update(query=query, limit=limit, api_key=api_key)
|
||||
return {"success": True, "data": {"web": []}}
|
||||
|
||||
monkeypatch.setattr(pp, "_mcp_web_search", _fake)
|
||||
out = pp.ParallelWebSearchProvider().search("kittens", limit=4)
|
||||
assert out["success"] is True
|
||||
assert captured == {"query": "kittens", "limit": 4, "api_key": None}
|
||||
|
||||
def test_is_available_reflects_key(self, monkeypatch):
|
||||
# is_available() gates the registry's active-provider walk + picker, so
|
||||
# it's key-based (keyless dispatch is handled by _get_backend, not this).
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
assert pp.ParallelWebSearchProvider().is_available() is False
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "k")
|
||||
assert pp.ParallelWebSearchProvider().is_available() is True
|
||||
|
||||
|
||||
# ─── web_fetch (keyless extract) ─────────────────────────────────────────────
|
||||
|
||||
class TestMcpWebFetch:
|
||||
def _payload(self, urls):
|
||||
return {"extract_id": "e1", "results": [
|
||||
{"url": u, "title": f"T{i}", "publish_date": None,
|
||||
"excerpts": [f"chunk-a-{i}", f"chunk-b-{i}"]}
|
||||
for i, u in enumerate(urls)
|
||||
]}
|
||||
|
||||
def test_maps_to_extract_shape(self):
|
||||
urls = ["https://a.test", "https://b.test"]
|
||||
fake = _FakeClient(self._payload(urls))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(urls, api_key=None)
|
||||
assert [r["url"] for r in out] == urls
|
||||
assert out[0]["content"] == "chunk-a-0\n\nchunk-b-0"
|
||||
assert out[0]["raw_content"] == out[0]["content"]
|
||||
assert out[0]["metadata"] == {"sourceURL": "https://a.test", "title": "T0"}
|
||||
# tools/call targeted web_fetch, requesting full page bodies.
|
||||
args = fake.calls[-1]["json"]["params"]
|
||||
assert args["name"] == "web_fetch"
|
||||
assert args["arguments"]["urls"] == urls
|
||||
assert args["arguments"]["full_content"] is True
|
||||
assert args["arguments"]["session_id"].startswith(f"{pp._MCP_CLIENT_NAME}-")
|
||||
|
||||
def test_prefers_full_content_over_excerpts(self):
|
||||
payload = {"results": [
|
||||
{"url": "https://a.test", "title": "T",
|
||||
"excerpts": ["snippet"], "full_content": "the entire page body"},
|
||||
]}
|
||||
fake = _FakeClient(payload)
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(["https://a.test"], api_key=None)
|
||||
assert out[0]["content"] == "the entire page body"
|
||||
|
||||
def test_missing_url_becomes_error_entry(self):
|
||||
# Server returns only one of the two requested URLs.
|
||||
fake = _FakeClient(self._payload(["https://a.test"]))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(["https://a.test", "https://missing.test"], api_key=None)
|
||||
assert len(out) == 2
|
||||
missing = [r for r in out if r["url"] == "https://missing.test"][0]
|
||||
assert "error" in missing
|
||||
assert missing["content"] == ""
|
||||
|
||||
def test_preserves_order_and_duplicate_inputs(self):
|
||||
# MCP returns each unique URL once; output must still be one row per
|
||||
# input, in order, including the duplicate.
|
||||
fake = _FakeClient(self._payload(["https://a.test", "https://b.test"]))
|
||||
urls = ["https://b.test", "https://a.test", "https://b.test"]
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(urls, api_key=None)
|
||||
assert [r["url"] for r in out] == urls # one row per input, in order
|
||||
assert all("error" not in r for r in out) # all three resolved
|
||||
|
||||
def test_extract_without_key_uses_web_fetch(self, monkeypatch):
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
captured = {}
|
||||
|
||||
def _fake(urls, api_key):
|
||||
captured.update(urls=list(urls), api_key=api_key)
|
||||
return [{"url": urls[0], "title": "", "content": "x",
|
||||
"raw_content": "x", "metadata": {}}]
|
||||
|
||||
monkeypatch.setattr(pp, "_mcp_web_fetch", _fake)
|
||||
out = asyncio.run(pp.ParallelWebSearchProvider().extract(["https://x.test"]))
|
||||
assert out[0]["content"] == "x"
|
||||
assert captured == {"urls": ["https://x.test"], "api_key": None}
|
||||
|
||||
|
||||
# ─── keyed v1 REST search ────────────────────────────────────────────────────
|
||||
|
||||
class TestKeyedV1Search:
|
||||
def test_passes_max_results_and_omits_branding(self, monkeypatch):
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "pk-live")
|
||||
monkeypatch.delenv("PARALLEL_SEARCH_MODE", raising=False)
|
||||
captured = {}
|
||||
|
||||
class _Res:
|
||||
def __init__(self, url):
|
||||
self.url, self.title, self.excerpts = url, "T", ["x"]
|
||||
|
||||
class _Resp:
|
||||
results = [_Res(f"https://r/{i}") for i in range(10)]
|
||||
|
||||
class _Client:
|
||||
def search(self, **kw):
|
||||
captured.update(kw)
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(pp, "_get_sync_client", lambda: _Client())
|
||||
out = pp.ParallelWebSearchProvider().search("q", limit=7)
|
||||
|
||||
assert out["success"] is True
|
||||
# honors the caller's limit via advanced_settings.max_results
|
||||
assert captured["advanced_settings"] == {"max_results": 7}
|
||||
assert captured["mode"] == "advanced" # v1 default
|
||||
assert captured["session_id"].startswith(f"{pp._MCP_CLIENT_NAME}-") # per-call id
|
||||
assert len(out["data"]["web"]) == 7 # client-side slice
|
||||
# paid path: no free-tier attribution, no [Parallel] label signal
|
||||
assert "attribution" not in out
|
||||
assert "provider" not in out
|
||||
|
||||
|
||||
# ─── v1 search mode mapping ──────────────────────────────────────────────────
|
||||
|
||||
class TestResolveSearchMode:
|
||||
@pytest.mark.parametrize("env,expected", [
|
||||
(None, "advanced"), # default
|
||||
("advanced", "advanced"),
|
||||
("basic", "basic"),
|
||||
("fast", "basic"), # legacy → basic
|
||||
("one-shot", "basic"), # legacy → basic
|
||||
("agentic", "advanced"), # legacy → advanced
|
||||
("garbage", "advanced"), # invalid → default
|
||||
("BASIC", "basic"), # case-insensitive
|
||||
])
|
||||
def test_mode_mapping(self, monkeypatch, env, expected):
|
||||
if env is None:
|
||||
monkeypatch.delenv("PARALLEL_SEARCH_MODE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("PARALLEL_SEARCH_MODE", env)
|
||||
assert pp._resolve_search_mode() == expected
|
||||
|
|
@ -193,11 +193,16 @@ class TestIsAvailable:
|
|||
assert p.is_available() is True
|
||||
|
||||
def test_parallel_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""is_available() is key-based — it gates the registry's active-provider
|
||||
walk/picker. (Keyless search/extract still work via the free MCP through
|
||||
_get_backend's terminal default, independent of this flag.)
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("parallel")
|
||||
assert p is not None
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
|
@ -422,17 +427,33 @@ class TestErrorResponseShapes:
|
|||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
|
||||
def test_parallel_extract_returns_per_url_errors_when_unconfigured(self) -> None:
|
||||
def test_parallel_extract_keyless_uses_mcp_web_fetch(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Without a key, extract routes to the free MCP web_fetch tool rather
|
||||
than erroring. The MCP transport is mocked so the test stays offline."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
import plugins.web.parallel.provider as parallel_provider
|
||||
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
captured = {}
|
||||
|
||||
def _fake_fetch(urls, api_key):
|
||||
captured["urls"] = list(urls)
|
||||
captured["api_key"] = api_key
|
||||
return [{"url": urls[0], "title": "Example", "content": "body",
|
||||
"raw_content": "body", "metadata": {"sourceURL": urls[0]}}]
|
||||
|
||||
monkeypatch.setattr(parallel_provider, "_mcp_web_fetch", _fake_fetch)
|
||||
|
||||
p = get_provider("parallel")
|
||||
assert p is not None
|
||||
result = asyncio.run(p.extract(["https://example.com"]))
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert "error" in result[0]
|
||||
assert result[0]["url"] == "https://example.com"
|
||||
assert result[0]["content"] == "body"
|
||||
assert captured == {"urls": ["https://example.com"], "api_key": None}
|
||||
|
||||
def test_firecrawl_extract_returns_per_url_errors_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
|
|
|
|||
|
|
@ -1573,3 +1573,87 @@ class TestCopilotACPStreamingDecision:
|
|||
_use_streaming = False
|
||||
|
||||
assert _use_streaming is True
|
||||
|
||||
|
||||
class TestBedrockIamStreamingFallback:
|
||||
"""bedrock_converse streaming branch: IAM denial of
|
||||
InvokeModelWithResponseStream falls back to converse() inline and sets
|
||||
_disable_streaming for the rest of the session."""
|
||||
|
||||
def _make_bedrock_agent(self):
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model="anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent.api_mode = "bedrock_converse"
|
||||
agent._interrupt_requested = False
|
||||
return agent
|
||||
|
||||
def test_iam_denial_falls_back_inline_and_disables_streaming(self):
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock tests")
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
agent = self._make_bedrock_agent()
|
||||
|
||||
client = MagicMock()
|
||||
client.converse_stream.side_effect = ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "AccessDeniedException",
|
||||
"Message": (
|
||||
"User is not authorized to perform: "
|
||||
"bedrock:InvokeModelWithResponseStream"
|
||||
),
|
||||
}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
client.converse.return_value = {
|
||||
"output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"agent.bedrock_adapter._get_bedrock_runtime_client",
|
||||
return_value=client,
|
||||
):
|
||||
response = agent._interruptible_streaming_api_call(
|
||||
{"modelId": agent.model, "messages": []}
|
||||
)
|
||||
|
||||
client.converse.assert_called_once()
|
||||
assert response.choices[0].message.content == "hi"
|
||||
assert getattr(agent, "_disable_streaming", False) is True
|
||||
|
||||
def test_other_bedrock_errors_still_propagate(self):
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock tests")
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
agent = self._make_bedrock_agent()
|
||||
|
||||
client = MagicMock()
|
||||
client.converse_stream.side_effect = ClientError(
|
||||
error_response={
|
||||
"Error": {"Code": "ThrottlingException", "Message": "slow down"}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent.bedrock_adapter._get_bedrock_runtime_client",
|
||||
return_value=client,
|
||||
):
|
||||
with pytest.raises(ClientError):
|
||||
agent._interruptible_streaming_api_call(
|
||||
{"modelId": agent.model, "messages": []}
|
||||
)
|
||||
|
||||
client.converse.assert_not_called()
|
||||
assert getattr(agent, "_disable_streaming", False) is False
|
||||
|
|
|
|||
93
tests/run_agent/test_thinking_sig_recovery_persistence.py
Normal file
93
tests/run_agent/test_thinking_sig_recovery_persistence.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Regression tests for the thinking-block signature recovery.
|
||||
|
||||
The recovery in ``agent/conversation_loop.py`` strips ``reasoning_details``
|
||||
from ``api_messages`` (the API-call-time list rebuilt on every retry) and
|
||||
leaves ``messages`` (the canonical store) untouched. The previous
|
||||
implementation popped from ``messages`` directly, which never reached
|
||||
``api_messages`` because each entry in ``api_messages`` was a shallow
|
||||
copy of the corresponding entry in ``messages``, and the mutation also
|
||||
landed in ``state.db`` on the next ``_persist_session`` call, corrupting
|
||||
the conversation.
|
||||
|
||||
These tests cover the surface that the recovery touches in isolation:
|
||||
shallow copies share inner field references; popping a key from one dict
|
||||
does not remove it from the other; and a list of shallow copies behaves
|
||||
the same way.
|
||||
"""
|
||||
|
||||
|
||||
def _shallow_copies(messages):
|
||||
return [m.copy() for m in messages]
|
||||
|
||||
|
||||
def test_pop_on_shallow_copy_does_not_affect_source():
|
||||
rd = [{"type": "thinking", "thinking": "r", "signature": "s"}]
|
||||
src = {"role": "assistant", "content": "x", "reasoning_details": rd}
|
||||
cp = src.copy()
|
||||
|
||||
cp.pop("reasoning_details", None)
|
||||
|
||||
assert "reasoning_details" not in cp
|
||||
assert "reasoning_details" in src
|
||||
assert src["reasoning_details"] is rd
|
||||
|
||||
|
||||
def test_strip_api_messages_leaves_canonical_messages_intact():
|
||||
"""Mirrors the recovery: pop reasoning_details from api_messages only.
|
||||
|
||||
The canonical ``messages`` list keeps its reasoning_details so future
|
||||
persists carry the original signed blocks.
|
||||
"""
|
||||
rd_one = [{"type": "thinking", "thinking": "one", "signature": "sig_one"}]
|
||||
rd_two = [{"type": "thinking", "thinking": "two", "signature": "sig_two"}]
|
||||
messages = [
|
||||
{"role": "user", "content": "q1"},
|
||||
{"role": "assistant", "content": "a1", "reasoning_details": rd_one},
|
||||
{"role": "user", "content": "q2"},
|
||||
{"role": "assistant", "content": "a2", "reasoning_details": rd_two},
|
||||
]
|
||||
api_messages = _shallow_copies(messages)
|
||||
|
||||
stripped = 0
|
||||
for m in api_messages:
|
||||
if isinstance(m, dict) and "reasoning_details" in m:
|
||||
m.pop("reasoning_details", None)
|
||||
stripped += 1
|
||||
|
||||
assert stripped == 2
|
||||
assert all("reasoning_details" not in m for m in api_messages)
|
||||
canonical_rd = [
|
||||
m.get("reasoning_details") for m in messages if m["role"] == "assistant"
|
||||
]
|
||||
assert canonical_rd == [rd_one, rd_two]
|
||||
|
||||
|
||||
def test_strip_is_idempotent_when_run_twice():
|
||||
"""A second strip is a no-op when reasoning_details has already been
|
||||
removed from api_messages. Guards against a duplicate firing path.
|
||||
"""
|
||||
api_messages = [
|
||||
{"role": "assistant", "content": "a", "reasoning_details": [{"x": 1}]},
|
||||
{"role": "user", "content": "q"},
|
||||
]
|
||||
for _ in range(2):
|
||||
for m in api_messages:
|
||||
if isinstance(m, dict) and "reasoning_details" in m:
|
||||
m.pop("reasoning_details", None)
|
||||
|
||||
assert all("reasoning_details" not in m for m in api_messages)
|
||||
|
||||
|
||||
def test_strip_skips_messages_without_reasoning_details():
|
||||
api_messages = [
|
||||
{"role": "user", "content": "q"},
|
||||
{"role": "assistant", "content": "a"},
|
||||
{"role": "tool", "tool_call_id": "1", "content": "ok"},
|
||||
]
|
||||
snapshot = [dict(m) for m in api_messages]
|
||||
|
||||
for m in api_messages:
|
||||
if isinstance(m, dict) and "reasoning_details" in m:
|
||||
m.pop("reasoning_details", None)
|
||||
|
||||
assert api_messages == snapshot
|
||||
161
tests/test_empty_session_hygiene.py
Normal file
161
tests/test_empty_session_hygiene.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Tests for empty-session hygiene — gemini-cli#27770 port.
|
||||
|
||||
Starting the CLI and immediately quitting (or rotating sessions with /new)
|
||||
used to leave empty untitled rows in the session DB that clutter /resume
|
||||
and `hermes sessions list`. ``SessionDB.delete_session_if_empty`` removes
|
||||
a just-ended session row only when it never gained resumable content:
|
||||
no messages, no title, and no child sessions.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
session_db = SessionDB(db_path=tmp_path / "state.db")
|
||||
yield session_db
|
||||
session_db.close()
|
||||
|
||||
|
||||
class TestDeleteSessionIfEmpty:
|
||||
def test_deletes_empty_untitled_session(self, db):
|
||||
db.create_session(session_id="empty", source="cli", model="test")
|
||||
db.end_session("empty", "cli_close")
|
||||
|
||||
assert db.delete_session_if_empty("empty") is True
|
||||
assert db.get_session("empty") is None
|
||||
|
||||
def test_keeps_session_with_messages(self, db):
|
||||
db.create_session(session_id="busy", source="cli", model="test")
|
||||
db.append_message("busy", role="user", content="hello")
|
||||
db.end_session("busy", "cli_close")
|
||||
|
||||
assert db.delete_session_if_empty("busy") is False
|
||||
assert db.get_session("busy") is not None
|
||||
|
||||
def test_keeps_titled_session(self, db):
|
||||
"""A user-assigned title is resumable content even without messages."""
|
||||
db.create_session(session_id="titled", source="cli", model="test")
|
||||
db.set_session_title("titled", "Important plans")
|
||||
db.end_session("titled", "cli_close")
|
||||
|
||||
assert db.delete_session_if_empty("titled") is False
|
||||
assert db.get_session("titled") is not None
|
||||
|
||||
def test_keeps_session_with_children(self, db):
|
||||
"""A parent that spawned delegate subagent runs is not empty."""
|
||||
db.create_session(session_id="parent", source="cli", model="test")
|
||||
db.create_session(
|
||||
session_id="child",
|
||||
source="tool",
|
||||
model="test",
|
||||
parent_session_id="parent",
|
||||
)
|
||||
db.end_session("parent", "cli_close")
|
||||
|
||||
assert db.delete_session_if_empty("parent") is False
|
||||
assert db.get_session("parent") is not None
|
||||
assert db.get_session("child") is not None
|
||||
|
||||
def test_unknown_session_returns_false(self, db):
|
||||
assert db.delete_session_if_empty("nope") is False
|
||||
|
||||
def test_removes_on_disk_transcripts(self, db, tmp_path):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
(sessions_dir / "empty.json").write_text("{}", encoding="utf-8")
|
||||
(sessions_dir / "empty.jsonl").write_text("", encoding="utf-8")
|
||||
|
||||
db.create_session(session_id="empty", source="cli", model="test")
|
||||
db.end_session("empty", "cli_close")
|
||||
|
||||
assert db.delete_session_if_empty("empty", sessions_dir=sessions_dir)
|
||||
assert not (sessions_dir / "empty.json").exists()
|
||||
assert not (sessions_dir / "empty.jsonl").exists()
|
||||
|
||||
def test_no_file_cleanup_when_kept(self, db, tmp_path):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
(sessions_dir / "busy.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
db.create_session(session_id="busy", source="cli", model="test")
|
||||
db.append_message("busy", role="user", content="hello")
|
||||
|
||||
assert not db.delete_session_if_empty("busy", sessions_dir=sessions_dir)
|
||||
assert (sessions_dir / "busy.json").exists()
|
||||
|
||||
def test_empty_session_disappears_from_listing(self, db):
|
||||
"""The user-facing symptom: empty rows polluting session lists."""
|
||||
db.create_session(session_id="real", source="cli", model="test")
|
||||
db.append_message("real", role="user", content="do the thing")
|
||||
db.end_session("real", "cli_close")
|
||||
|
||||
db.create_session(session_id="ghost", source="cli", model="test")
|
||||
db.end_session("ghost", "cli_close")
|
||||
|
||||
ids_before = {s["id"] for s in db.list_sessions_rich(source="cli")}
|
||||
assert {"real", "ghost"} <= ids_before
|
||||
|
||||
db.delete_session_if_empty("ghost")
|
||||
|
||||
ids_after = {s["id"] for s in db.list_sessions_rich(source="cli")}
|
||||
assert "real" in ids_after
|
||||
assert "ghost" not in ids_after
|
||||
|
||||
|
||||
class TestCLIDiscardSessionIfEmpty:
|
||||
"""Wiring tests for HermesCLI._discard_session_if_empty."""
|
||||
|
||||
def _make_cli(self, db):
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._session_db = db
|
||||
cli.conversation_history = []
|
||||
return cli
|
||||
|
||||
def test_discards_empty(self, db):
|
||||
db.create_session(session_id="empty", source="cli", model="test")
|
||||
db.end_session("empty", "cli_close")
|
||||
|
||||
cli = self._make_cli(db)
|
||||
assert cli._discard_session_if_empty("empty") is True
|
||||
assert db.get_session("empty") is None
|
||||
|
||||
def test_keeps_nonempty(self, db):
|
||||
db.create_session(session_id="busy", source="cli", model="test")
|
||||
db.append_message("busy", role="user", content="hi")
|
||||
|
||||
cli = self._make_cli(db)
|
||||
assert cli._discard_session_if_empty("busy") is False
|
||||
assert db.get_session("busy") is not None
|
||||
|
||||
def test_no_db_is_noop(self):
|
||||
cli = self._make_cli(None)
|
||||
assert cli._discard_session_if_empty("anything") is False
|
||||
|
||||
def test_none_session_id_is_noop(self, db):
|
||||
cli = self._make_cli(db)
|
||||
assert cli._discard_session_if_empty(None) is False
|
||||
|
||||
def test_db_error_swallowed(self, db):
|
||||
class Boom:
|
||||
def delete_session_if_empty(self, *a, **k):
|
||||
raise RuntimeError("locked")
|
||||
|
||||
cli = self._make_cli(Boom())
|
||||
assert cli._discard_session_if_empty("x") is False
|
||||
|
||||
def test_in_memory_history_blocks_prune(self, db):
|
||||
"""The live transcript is authoritative: even if the DB row has no
|
||||
flushed messages yet, a CLI holding conversation history must not
|
||||
prune the session (covers flush-failed / not-yet-flushed turns)."""
|
||||
db.create_session(session_id="unflushed", source="cli", model="test")
|
||||
db.end_session("unflushed", "new_session")
|
||||
|
||||
cli = self._make_cli(db)
|
||||
cli.conversation_history = [{"role": "user", "content": "hello"}]
|
||||
assert cli._discard_session_if_empty("unflushed") is False
|
||||
assert db.get_session("unflushed") is not None
|
||||
|
|
@ -86,6 +86,47 @@ def test_session_context_uses_session_cwd(monkeypatch, tmp_path):
|
|||
server._sessions.pop(sid, None)
|
||||
|
||||
|
||||
def test_handoff_fail_marks_only_inflight_rows(monkeypatch):
|
||||
class DbContext:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def __enter__(self):
|
||||
return self.db
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self, state):
|
||||
self.state = state
|
||||
self.failed_with = None
|
||||
|
||||
def get_handoff_state(self, _key):
|
||||
return {"state": self.state, "platform": "telegram", "error": None}
|
||||
|
||||
def fail_handoff(self, _key, error):
|
||||
self.failed_with = error
|
||||
self.state = "failed"
|
||||
|
||||
sid = "rt-handoff"
|
||||
server._sessions[sid] = {"session_key": "stored-handoff"}
|
||||
try:
|
||||
pending = FakeDb("pending")
|
||||
monkeypatch.setattr(server, "_session_db", lambda _session: DbContext(pending))
|
||||
result = server._methods["handoff.fail"]("r1", {"session_id": sid, "error": "timed out"})
|
||||
assert result["result"] == {"failed": True, "state": "failed"}
|
||||
assert pending.failed_with == "timed out"
|
||||
|
||||
completed = FakeDb("completed")
|
||||
monkeypatch.setattr(server, "_session_db", lambda _session: DbContext(completed))
|
||||
result = server._methods["handoff.fail"]("r2", {"session_id": sid, "error": "late timeout"})
|
||||
assert result["result"] == {"failed": False, "state": "completed"}
|
||||
assert completed.failed_with is None
|
||||
finally:
|
||||
server._sessions.pop(sid, None)
|
||||
|
||||
|
||||
def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path):
|
||||
"""Background/preview tasks use ephemeral ids absent from `_sessions`, so the
|
||||
parent workspace is passed explicitly; it must pin instead of clearing back
|
||||
|
|
|
|||
|
|
@ -452,3 +452,54 @@ class TestUnifiedCronjobTool:
|
|||
assert updated["success"] is True
|
||||
stored = get_job(created["job_id"])
|
||||
assert stored["deliver"] == "telegram"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Per-job model/provider override resolution
|
||||
# =========================================================================
|
||||
|
||||
from tools.cronjob_tools import _resolve_model_override # noqa: E402
|
||||
|
||||
|
||||
class TestResolveModelOverride:
|
||||
"""`_resolve_model_override` must not silently hijack a job that meant to
|
||||
use a configured custom endpoint (e.g. ``providers.custom`` → cliproxy).
|
||||
Regression for cron jobs with ``provider: "custom"`` falling back to codex.
|
||||
"""
|
||||
|
||||
def test_keeps_bare_custom_when_a_named_entry_exists(self, monkeypatch):
|
||||
import hermes_cli.runtime_provider as rp_mod
|
||||
|
||||
monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: True)
|
||||
provider, model = _resolve_model_override(
|
||||
{"provider": "custom", "model": "gpt-5.4"}
|
||||
)
|
||||
assert provider == "custom"
|
||||
assert model == "gpt-5.4"
|
||||
|
||||
def test_pins_main_provider_when_bare_custom_unresolvable(self, monkeypatch):
|
||||
import hermes_cli.config as cfg_mod
|
||||
import hermes_cli.runtime_provider as rp_mod
|
||||
|
||||
monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: False)
|
||||
monkeypatch.setattr(
|
||||
cfg_mod, "load_config", lambda: {"model": {"provider": "openai-codex"}}
|
||||
)
|
||||
provider, model = _resolve_model_override(
|
||||
{"provider": "custom", "model": "gpt-5.4"}
|
||||
)
|
||||
# No matching custom entry → fall back to pinning the main provider.
|
||||
assert provider == "openai-codex"
|
||||
assert model == "gpt-5.4"
|
||||
|
||||
def test_keeps_explicit_custom_name_unchanged(self, monkeypatch):
|
||||
import hermes_cli.runtime_provider as rp_mod
|
||||
|
||||
# Even if the resolver claims no entry, the canonical "custom:<name>"
|
||||
# form is never stripped or pinned.
|
||||
monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: False)
|
||||
provider, model = _resolve_model_override(
|
||||
{"provider": "custom:cliproxy", "model": "gpt-5.4"}
|
||||
)
|
||||
assert provider == "custom:cliproxy"
|
||||
assert model == "gpt-5.4"
|
||||
|
|
|
|||
139
tests/tools/test_mcp_loop_profile_override.py
Normal file
139
tests/tools/test_mcp_loop_profile_override.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""Regression tests for HERMES_HOME override propagation onto the MCP loop.
|
||||
|
||||
Tasks scheduled via run_coroutine_threadsafe are created inside the MCP
|
||||
event-loop thread, so they copy THAT thread's context — not the scheduling
|
||||
thread's. A per-request profile scope (dashboard ?profile= endpoints, e.g.
|
||||
the MCP "Test server" probe) would silently vanish for anything resolving
|
||||
get_hermes_home() inside the coroutine, most visibly OAuth token-store
|
||||
paths. _run_on_mcp_loop now wraps scheduled coroutines with the caller's
|
||||
override (mcp_tool._wrap_with_home_override).
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_loop():
|
||||
import tools.mcp_tool as mcp_tool
|
||||
|
||||
mcp_tool._ensure_mcp_loop()
|
||||
yield mcp_tool
|
||||
mcp_tool._stop_mcp_loop()
|
||||
|
||||
|
||||
def test_override_propagates_to_mcp_loop(tmp_path, monkeypatch, mcp_loop):
|
||||
from hermes_constants import (
|
||||
get_hermes_home,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
process_home = tmp_path / "proc-home"
|
||||
profile_home = tmp_path / "profile-home"
|
||||
process_home.mkdir()
|
||||
profile_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(process_home))
|
||||
|
||||
async def read_home():
|
||||
return str(get_hermes_home())
|
||||
|
||||
# Unscoped: the loop task sees the process home.
|
||||
assert mcp_loop._run_on_mcp_loop(read_home(), timeout=10) == str(process_home)
|
||||
|
||||
# Scoped: the caller's override must reach the loop task.
|
||||
token = set_hermes_home_override(str(profile_home))
|
||||
try:
|
||||
assert mcp_loop._run_on_mcp_loop(read_home(), timeout=10) == str(profile_home)
|
||||
# Factory form must be wrapped too.
|
||||
assert mcp_loop._run_on_mcp_loop(lambda: read_home(), timeout=10) == str(
|
||||
profile_home
|
||||
)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
# The loop thread's default context is untouched afterwards.
|
||||
assert mcp_loop._run_on_mcp_loop(read_home(), timeout=10) == str(process_home)
|
||||
|
||||
|
||||
def test_oauth_token_paths_follow_override(tmp_path, monkeypatch, mcp_loop):
|
||||
"""The actual symptom path: HermesTokenStorage resolving inside the
|
||||
probe's MCP-loop coroutine must land in the selected profile's
|
||||
mcp-tokens dir, not the process home's."""
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
process_home = tmp_path / "proc-home"
|
||||
profile_home = tmp_path / "profile-home"
|
||||
process_home.mkdir()
|
||||
profile_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(process_home))
|
||||
|
||||
async def token_path():
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
|
||||
return str(HermesTokenStorage("probe-srv")._tokens_path())
|
||||
|
||||
token = set_hermes_home_override(str(profile_home))
|
||||
try:
|
||||
path = mcp_loop._run_on_mcp_loop(token_path(), timeout=10)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
assert path.startswith(str(profile_home))
|
||||
assert os.path.join("mcp-tokens", "probe-srv.json") in path
|
||||
|
||||
|
||||
def test_concurrent_scopes_do_not_interfere(tmp_path, monkeypatch, mcp_loop):
|
||||
"""Two threads carrying DIFFERENT overrides scheduling onto the same
|
||||
loop must each see their own home — the wrapper is task-local."""
|
||||
import threading
|
||||
|
||||
from hermes_constants import (
|
||||
get_hermes_home,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
process_home = tmp_path / "proc-home"
|
||||
home_a = tmp_path / "profile-a"
|
||||
home_b = tmp_path / "profile-b"
|
||||
for h in (process_home, home_a, home_b):
|
||||
h.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(process_home))
|
||||
|
||||
async def read_home():
|
||||
return str(get_hermes_home())
|
||||
|
||||
results: dict = {}
|
||||
|
||||
def scoped_call(key, home):
|
||||
token = set_hermes_home_override(str(home))
|
||||
try:
|
||||
results[key] = mcp_loop._run_on_mcp_loop(read_home(), timeout=10)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=scoped_call, args=("a", home_a)),
|
||||
threading.Thread(target=scoped_call, args=("b", home_b)),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=15)
|
||||
|
||||
assert results == {"a": str(home_a), "b": str(home_b)}
|
||||
|
||||
|
||||
def test_wrap_is_noop_without_override(mcp_loop):
|
||||
"""No active override → the coroutine passes through unwrapped."""
|
||||
|
||||
async def trivial():
|
||||
return 42
|
||||
|
||||
coro = trivial()
|
||||
wrapped = mcp_loop._wrap_with_home_override(coro)
|
||||
assert wrapped is coro
|
||||
coro.close()
|
||||
|
|
@ -90,6 +90,30 @@ def test_cached_sudo_password_is_used_when_env_is_unset(monkeypatch):
|
|||
assert sudo_stdin == "cached-pass\n"
|
||||
|
||||
|
||||
def test_registered_sudo_callback_is_used_without_interactive_env(monkeypatch):
|
||||
monkeypatch.delenv("SUDO_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
monkeypatch.setattr(terminal_tool, "_sudo_nopasswd_works", lambda: False)
|
||||
|
||||
calls = []
|
||||
|
||||
def sudo_callback():
|
||||
calls.append("called")
|
||||
return "callback-pass"
|
||||
|
||||
terminal_tool.set_sudo_password_callback(sudo_callback)
|
||||
try:
|
||||
transformed, sudo_stdin = terminal_tool._transform_sudo_command(
|
||||
"echo ok | sudo tee /tmp/hermes-test"
|
||||
)
|
||||
finally:
|
||||
terminal_tool.set_sudo_password_callback(None)
|
||||
|
||||
assert calls == ["called"]
|
||||
assert transformed == "echo ok | sudo -S -p '' tee /tmp/hermes-test"
|
||||
assert sudo_stdin == "callback-pass\n"
|
||||
|
||||
|
||||
def test_cached_sudo_password_isolated_by_session_key(monkeypatch):
|
||||
monkeypatch.delenv("SUDO_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
|
|
|||
|
|
@ -167,6 +167,21 @@ class TestPerCapabilityBackendSelection:
|
|||
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
|
||||
assert web_tools._get_search_backend() == "tavily"
|
||||
|
||||
def test_explicit_extract_backend_honored_when_unavailable(self, monkeypatch):
|
||||
"""An explicit per-capability backend is honored even with no creds, so
|
||||
its setup error surfaces instead of silently rerouting to the keyless
|
||||
Parallel default (which would send user URLs to a different provider)."""
|
||||
from tools import web_tools
|
||||
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {
|
||||
"extract_backend": "firecrawl",
|
||||
})
|
||||
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False, raising=False)
|
||||
# Resolves to firecrawl (not parallel) despite firecrawl being unavailable.
|
||||
assert web_tools._get_extract_backend() == "firecrawl"
|
||||
|
||||
def test_falls_back_to_generic_backend_when_extract_backend_empty(self, monkeypatch):
|
||||
from tools import web_tools
|
||||
|
||||
|
|
@ -177,7 +192,7 @@ class TestPerCapabilityBackendSelection:
|
|||
monkeypatch.setenv("PARALLEL_API_KEY", "test-key")
|
||||
assert web_tools._get_extract_backend() == "parallel"
|
||||
|
||||
def test_search_backend_ignored_when_not_available(self, monkeypatch):
|
||||
def test_explicit_search_backend_honored_when_unavailable(self, monkeypatch):
|
||||
from tools import web_tools
|
||||
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {
|
||||
|
|
@ -186,8 +201,10 @@ class TestPerCapabilityBackendSelection:
|
|||
})
|
||||
monkeypatch.delenv("EXA_API_KEY", raising=False)
|
||||
monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-key")
|
||||
# Should fall back to firecrawl since exa isn't configured
|
||||
assert web_tools._get_search_backend() == "firecrawl"
|
||||
# The explicit per-capability choice (exa) is honored even though it's
|
||||
# unavailable, so its setup error surfaces — we don't silently reroute
|
||||
# to the shared backend (or the keyless Parallel default).
|
||||
assert web_tools._get_search_backend() == "exa"
|
||||
|
||||
def test_fully_backward_compatible_with_web_backend_only(self, monkeypatch):
|
||||
from tools import web_tools
|
||||
|
|
@ -291,25 +308,55 @@ class TestUnconfiguredErrorEnvelopeParity:
|
|||
):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
|
||||
def test_unconfigured_search_emits_top_level_error(self, monkeypatch):
|
||||
"""``web_search_tool`` with no creds returns ``{"error": "Error searching web: ..."}``
|
||||
— matching main's ``tool_error()`` envelope, not a per-result shape.
|
||||
def test_extract_empty_urls_does_not_raise(self, monkeypatch):
|
||||
"""Regression: empty (or fully SSRF-blocked) URL sets skip the dispatch
|
||||
branch; the free-Parallel flag must still be initialized so the tool
|
||||
returns an error envelope instead of UnboundLocalError."""
|
||||
import asyncio
|
||||
from tools import web_tools
|
||||
self._clear_web_creds(monkeypatch)
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
|
||||
out = asyncio.run(web_tools.web_extract_tool([], "markdown"))
|
||||
# The key assertion is that it returns a normal error envelope (a
|
||||
# string) rather than raising UnboundLocalError.
|
||||
assert isinstance(out, str)
|
||||
result = json.loads(out)
|
||||
assert "error" in result
|
||||
|
||||
def test_unconfigured_search_falls_back_to_free_parallel(self, monkeypatch):
|
||||
"""``web_search_tool`` with no creds routes to Parallel's free Search
|
||||
MCP rather than erroring. The MCP transport is mocked so the test
|
||||
stays offline; we assert dispatch landed on parallel and returned the
|
||||
standard search envelope.
|
||||
"""
|
||||
from tools import web_tools
|
||||
import plugins.web.parallel.provider as parallel_provider
|
||||
|
||||
self._clear_web_creds(monkeypatch)
|
||||
# Reset firecrawl client cache so the unconfigured state is re-evaluated
|
||||
monkeypatch.setattr(web_tools, "_firecrawl_client", None, raising=False)
|
||||
monkeypatch.setattr(web_tools, "_firecrawl_client_config", None, raising=False)
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_mcp(query, limit, api_key):
|
||||
captured["query"] = query
|
||||
captured["api_key"] = api_key
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"web": [
|
||||
{"url": "https://example.com", "title": "Example",
|
||||
"description": "hit", "position": 1},
|
||||
]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(parallel_provider, "_mcp_web_search", _fake_mcp)
|
||||
|
||||
result = json.loads(web_tools.web_search_tool("hello world", limit=3))
|
||||
assert "error" in result, f"expected top-level 'error' key, got {result}"
|
||||
# ``Error searching web:`` prefix comes from web_tools' top-level except handler
|
||||
assert "Error searching web:" in result["error"]
|
||||
assert "FIRECRAWL_API_KEY" in result["error"]
|
||||
# No per-result burying
|
||||
assert "results" not in result
|
||||
assert result.get("success") is True, f"expected success, got {result}"
|
||||
assert result["data"]["web"][0]["url"] == "https://example.com"
|
||||
# Keyless path: dispatched to parallel with no Bearer token.
|
||||
assert captured == {"query": "hello world", "api_key": None}
|
||||
|
||||
|
||||
class TestDispatchersTriggerPluginDiscovery:
|
||||
|
|
|
|||
|
|
@ -190,7 +190,11 @@ class TestDDGSBackendWiring:
|
|||
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True)
|
||||
assert web_tools._get_backend() == "exa"
|
||||
|
||||
def test_auto_detect_picks_ddgs_as_last_resort(self, monkeypatch):
|
||||
def test_auto_detect_prefers_keyless_parallel_over_ddgs(self, monkeypatch):
|
||||
# With no credentials, keyless Parallel is the auto-detect default even
|
||||
# when the ddgs package is installed — ddgs is search-only (can't
|
||||
# extract), so Parallel is preferred so both search and extract work.
|
||||
# ddgs remains reachable via an explicit web.backend=ddgs.
|
||||
from tools import web_tools
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
|
||||
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "PARALLEL_API_KEY",
|
||||
|
|
@ -198,7 +202,7 @@ class TestDDGSBackendWiring:
|
|||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False)
|
||||
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True)
|
||||
assert web_tools._get_backend() == "ddgs"
|
||||
assert web_tools._get_backend() == "parallel"
|
||||
|
||||
def test_check_web_api_key_true_when_ddgs_configured(self, monkeypatch):
|
||||
from tools import web_tools
|
||||
|
|
|
|||
|
|
@ -313,7 +313,9 @@ class TestCheckWebApiKey:
|
|||
)
|
||||
assert web_tools.check_web_api_key() is True
|
||||
|
||||
def test_no_credentials_fails(self, monkeypatch):
|
||||
def test_no_credentials_usable_via_free_parallel(self, monkeypatch):
|
||||
"""No credentials → check_web_api_key True: the keyless Parallel free MCP
|
||||
services calls, so web is usable out of the box."""
|
||||
from tools import web_tools
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
|
||||
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
|
||||
|
|
@ -324,7 +326,8 @@ class TestCheckWebApiKey:
|
|||
monkeypatch.delenv("SEARXNG_URL", raising=False)
|
||||
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False)
|
||||
monkeypatch.setattr(web_tools, "check_firecrawl_api_key", lambda: False)
|
||||
assert web_tools.check_web_api_key() is False
|
||||
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False)
|
||||
assert web_tools.check_web_api_key() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -384,11 +384,14 @@ class TestBackendSelection:
|
|||
patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
|
||||
assert _get_backend() == "firecrawl"
|
||||
|
||||
def test_fallback_no_keys_defaults_to_firecrawl(self):
|
||||
"""No keys, no config → 'firecrawl' (will fail at client init)."""
|
||||
def test_fallback_no_keys_defaults_to_parallel(self):
|
||||
"""No credentials, no config → 'parallel' (free Search MCP works
|
||||
keyless). Selection is purely credential-based."""
|
||||
from tools.web_tools import _get_backend
|
||||
with patch("tools.web_tools._load_web_config", return_value={}):
|
||||
assert _get_backend() == "firecrawl"
|
||||
with patch("tools.web_tools._load_web_config", return_value={}), \
|
||||
patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \
|
||||
patch("tools.web_tools._ddgs_package_importable", return_value=False):
|
||||
assert _get_backend() == "parallel"
|
||||
|
||||
def test_invalid_config_falls_through_to_fallback(self):
|
||||
"""web.backend=invalid → ignored, uses key-based fallback."""
|
||||
|
|
@ -623,9 +626,74 @@ class TestCheckWebApiKey:
|
|||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is True
|
||||
|
||||
def test_no_keys_returns_false(self):
|
||||
def test_no_keys_usable_via_free_parallel(self):
|
||||
"""No credentials → check_web_api_key True: selection resolves to the
|
||||
keyless Parallel free MCP, which genuinely services calls (web works out
|
||||
of the box). check_web_api_key is a usability probe, not a key check."""
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is False
|
||||
with patch("tools.web_tools._load_web_config", return_value={}), \
|
||||
patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \
|
||||
patch("tools.web_tools._ddgs_package_importable", return_value=False), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
for k in ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL",
|
||||
"TAVILY_API_KEY", "EXA_API_KEY", "SEARXNG_URL", "BRAVE_SEARCH_API_KEY"):
|
||||
os.environ.pop(k, None)
|
||||
assert check_web_api_key() is True
|
||||
|
||||
def test_typo_extract_backend_not_masked_by_parallel(self):
|
||||
"""A typo'd per-capability backend is honored (so dispatch errors)
|
||||
rather than silently falling through to keyless Parallel."""
|
||||
from tools.web_tools import _get_extract_backend, check_web_api_key
|
||||
with patch("tools.web_tools._load_web_config",
|
||||
return_value={"extract_backend": "parrallel"}):
|
||||
assert _get_extract_backend() == "parrallel" # not "parallel"
|
||||
assert check_web_api_key() is False # unknown → unusable
|
||||
|
||||
def test_keyless_parallel_unusable_when_provider_disabled(self):
|
||||
"""If the bundled web-parallel provider is disabled/unregistered, the
|
||||
keyless free-MCP path must NOT report web as usable — otherwise setup is
|
||||
skipped but web tools fail at runtime with no provider."""
|
||||
from tools.web_tools import check_web_api_key
|
||||
with patch("tools.web_tools._load_web_config", return_value={}), \
|
||||
patch("tools.web_tools._parallel_provider_registered", return_value=False), \
|
||||
patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \
|
||||
patch("tools.web_tools.check_firecrawl_api_key", return_value=False), \
|
||||
patch("tools.web_tools._ddgs_package_importable", return_value=False), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
for var in (
|
||||
"PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL",
|
||||
"TAVILY_API_KEY", "EXA_API_KEY", "BRAVE_SEARCH_API_KEY", "SEARXNG_URL",
|
||||
):
|
||||
os.environ.pop(var, None)
|
||||
assert check_web_api_key() is False
|
||||
|
||||
def test_extract_autodetect_skips_search_only_for_keyless_parallel(self):
|
||||
"""A search-only env credential (SEARXNG_URL) must not shadow the keyless
|
||||
Parallel free-MCP extract fallback: extract auto-detect skips search-only
|
||||
backends, so _get_extract_backend resolves to parallel (which can fetch),
|
||||
while search auto-detect still prefers the configured searxng."""
|
||||
from tools.web_tools import _get_extract_backend, _get_search_backend
|
||||
with patch("tools.web_tools._load_web_config", return_value={}), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
for var in (
|
||||
"PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL",
|
||||
"TAVILY_API_KEY", "EXA_API_KEY", "BRAVE_SEARCH_API_KEY",
|
||||
):
|
||||
os.environ.pop(var, None)
|
||||
os.environ["SEARXNG_URL"] = "http://localhost:8080"
|
||||
with patch("tools.web_tools._is_tool_gateway_ready", return_value=False):
|
||||
assert _get_search_backend() == "searxng"
|
||||
assert _get_extract_backend() == "parallel"
|
||||
|
||||
def test_configured_but_unavailable_backend_reports_unusable(self):
|
||||
"""An explicitly configured backend with no creds (exa, no key) →
|
||||
check_web_api_key False so diagnostics flag the misconfiguration —
|
||||
even though the tools stay registered."""
|
||||
from tools.web_tools import check_web_api_key
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "exa"}), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("EXA_API_KEY", None)
|
||||
assert check_web_api_key() is False
|
||||
|
||||
def test_both_keys_returns_true(self):
|
||||
with patch.dict(os.environ, {
|
||||
|
|
@ -688,12 +756,18 @@ class TestCheckWebApiKey:
|
|||
|
||||
assert refresh_calls == []
|
||||
|
||||
def test_configured_backend_must_match_available_provider(self):
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is False
|
||||
def test_web_tools_registered_even_when_configured_backend_unavailable(self):
|
||||
# Registration is unconditional (web_tools_registered) so an explicitly
|
||||
# configured but unavailable backend (exa without EXA_API_KEY) keeps the
|
||||
# tools registered to surface exa's setup error at call time — while the
|
||||
# readiness probe (check_web_api_key) honestly reports not-configured.
|
||||
from tools.web_tools import web_tools_registered, check_web_api_key
|
||||
assert web_tools_registered() is True
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "exa"}), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("EXA_API_KEY", None)
|
||||
assert web_tools_registered() is True
|
||||
assert check_web_api_key() is False
|
||||
|
||||
def test_configured_firecrawl_backend_accepts_managed_gateway(self):
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue