Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -170,33 +170,7 @@ class TestFlushDeduplication:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAppendToTranscriptSkipDb:
|
||||
"""Verify skip_db=True writes JSONL but not SQLite."""
|
||||
|
||||
@pytest.fixture()
|
||||
def store(self, tmp_path):
|
||||
from gateway.config import GatewayConfig
|
||||
from gateway.session import SessionStore
|
||||
config = GatewayConfig()
|
||||
with patch("gateway.session.SessionStore._ensure_loaded"):
|
||||
s = SessionStore(sessions_dir=tmp_path, config=config)
|
||||
s._db = None # no SQLite for these JSONL-focused tests
|
||||
s._loaded = True
|
||||
return s
|
||||
|
||||
def test_skip_db_writes_jsonl_only(self, store, tmp_path):
|
||||
"""With skip_db=True, message appears in JSONL but not SQLite."""
|
||||
session_id = "test-skip-db"
|
||||
msg = {"role": "assistant", "content": "hello world"}
|
||||
store.append_to_transcript(session_id, msg, skip_db=True)
|
||||
|
||||
# JSONL should have the message
|
||||
jsonl_path = store.get_transcript_path(session_id)
|
||||
assert jsonl_path.exists()
|
||||
with open(jsonl_path) as f:
|
||||
lines = f.readlines()
|
||||
assert len(lines) == 1
|
||||
parsed = json.loads(lines[0])
|
||||
assert parsed["content"] == "hello world"
|
||||
"""Verify skip_db=True skips the SQLite write."""
|
||||
|
||||
def test_skip_db_prevents_sqlite_write(self, tmp_path):
|
||||
"""With skip_db=True and a real DB, message does NOT appear in SQLite."""
|
||||
@@ -223,14 +197,8 @@ class TestAppendToTranscriptSkipDb:
|
||||
rows = db.get_messages(session_id)
|
||||
assert len(rows) == 0, f"Expected 0 DB rows with skip_db=True, got {len(rows)}"
|
||||
|
||||
# But JSONL should have it
|
||||
jsonl_path = store.get_transcript_path(session_id)
|
||||
with open(jsonl_path) as f:
|
||||
lines = f.readlines()
|
||||
assert len(lines) == 1
|
||||
|
||||
def test_default_writes_both(self, tmp_path):
|
||||
"""Without skip_db, message appears in both JSONL and SQLite."""
|
||||
def test_default_writes_to_sqlite(self, tmp_path):
|
||||
"""Without skip_db, message appears in SQLite."""
|
||||
from gateway.config import GatewayConfig
|
||||
from gateway.session import SessionStore
|
||||
from hermes_state import SessionDB
|
||||
@@ -250,13 +218,7 @@ class TestAppendToTranscriptSkipDb:
|
||||
msg = {"role": "user", "content": "test message"}
|
||||
store.append_to_transcript(session_id, msg)
|
||||
|
||||
# JSONL should have the message
|
||||
jsonl_path = store.get_transcript_path(session_id)
|
||||
with open(jsonl_path) as f:
|
||||
lines = f.readlines()
|
||||
assert len(lines) == 1
|
||||
|
||||
# SQLite should also have the message
|
||||
# SQLite should have the message
|
||||
rows = db.get_messages(session_id)
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ def _make_agent_stub(agent_cls):
|
||||
agent._MEMORY_REVIEW_PROMPT = "review memory"
|
||||
agent._SKILL_REVIEW_PROMPT = "review skills"
|
||||
agent._COMBINED_REVIEW_PROMPT = "review both"
|
||||
# Non-None so the test catches a missing-kwarg regression.
|
||||
agent.enabled_toolsets = ["memory", "skills", "terminal"]
|
||||
agent.disabled_toolsets = ["spotify", "feishu_doc"]
|
||||
return agent
|
||||
|
||||
|
||||
@@ -183,3 +186,54 @@ def test_review_fork_pins_session_start_and_session_id():
|
||||
"Review fork did not inherit parent's session_id — "
|
||||
"system-prompt rebuild paths would diverge."
|
||||
)
|
||||
|
||||
|
||||
def test_review_fork_inherits_parent_toolset_config():
|
||||
"""``tools[]`` byte-stability: fork must inherit parent's toolset config."""
|
||||
import run_agent
|
||||
|
||||
agent = _make_agent_stub(run_agent.AIAgent)
|
||||
|
||||
captured = {}
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self, *args, **kwargs):
|
||||
captured["enabled_toolsets"] = kwargs.get("enabled_toolsets")
|
||||
captured["disabled_toolsets"] = kwargs.get("disabled_toolsets")
|
||||
self._cached_system_prompt = None
|
||||
self._memory_write_origin = None
|
||||
self._memory_write_context = None
|
||||
self._memory_store = None
|
||||
self._memory_enabled = None
|
||||
self._user_profile_enabled = None
|
||||
self._memory_nudge_interval = None
|
||||
self._skill_nudge_interval = None
|
||||
self.suppress_status_output = None
|
||||
self.session_start = None
|
||||
self.session_id = None
|
||||
|
||||
def run_conversation(self, *args, **kwargs):
|
||||
raise RuntimeError("stop after recording — don't actually call the API")
|
||||
|
||||
def shutdown_memory_provider(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch.object(run_agent, "AIAgent", _Recorder), \
|
||||
patch("threading.Thread", _SyncThread):
|
||||
agent._spawn_background_review(
|
||||
messages_snapshot=[],
|
||||
review_memory=True,
|
||||
review_skills=False,
|
||||
)
|
||||
|
||||
assert captured.get("enabled_toolsets") == agent.enabled_toolsets, (
|
||||
f"enabled_toolsets mismatch: {captured.get('enabled_toolsets')!r} "
|
||||
f"vs expected {agent.enabled_toolsets!r}"
|
||||
)
|
||||
assert captured.get("disabled_toolsets") == agent.disabled_toolsets, (
|
||||
f"disabled_toolsets mismatch: {captured.get('disabled_toolsets')!r} "
|
||||
f"vs expected {agent.disabled_toolsets!r}"
|
||||
)
|
||||
|
||||
@@ -38,6 +38,9 @@ def _make_agent_stub(agent_cls):
|
||||
agent._MEMORY_REVIEW_PROMPT = "review memory"
|
||||
agent._SKILL_REVIEW_PROMPT = "review skills"
|
||||
agent._COMBINED_REVIEW_PROMPT = "review both"
|
||||
# Non-None so the test catches a missing-kwarg regression.
|
||||
agent.enabled_toolsets = ["memory", "skills", "terminal"]
|
||||
agent.disabled_toolsets = ["spotify", "feishu_doc"]
|
||||
return agent
|
||||
|
||||
|
||||
@@ -52,13 +55,8 @@ class _SyncThread:
|
||||
self._target()
|
||||
|
||||
|
||||
def test_background_review_does_not_narrow_toolset_schema():
|
||||
"""The review fork must NOT pass enabled_toolsets to AIAgent.
|
||||
|
||||
Narrowing the schema diverges the ``tools`` cache key from the parent's,
|
||||
which sits above ``system`` in Anthropic's cache hierarchy and forces a
|
||||
full prefix-cache miss on every review (see #25322, PR #17276).
|
||||
"""
|
||||
def test_background_review_matches_parent_toolset_config():
|
||||
"""Fork must receive parent's toolset config so ``tools[]`` cache key matches."""
|
||||
import run_agent
|
||||
|
||||
agent = _make_agent_stub(run_agent.AIAgent)
|
||||
@@ -66,6 +64,7 @@ def test_background_review_does_not_narrow_toolset_schema():
|
||||
|
||||
def _capture_init(self, *args, **kwargs):
|
||||
captured["enabled_toolsets"] = kwargs.get("enabled_toolsets", "UNSET")
|
||||
captured["disabled_toolsets"] = kwargs.get("disabled_toolsets", "UNSET")
|
||||
raise RuntimeError("stop after capturing init args")
|
||||
|
||||
with patch.object(run_agent.AIAgent, "__init__", _capture_init), \
|
||||
@@ -77,11 +76,13 @@ def test_background_review_does_not_narrow_toolset_schema():
|
||||
)
|
||||
|
||||
assert "enabled_toolsets" in captured, "AIAgent.__init__ was not called"
|
||||
# The kwarg must be absent — letting AIAgent inherit the default full
|
||||
# toolset so the schema bytes match the parent's.
|
||||
assert captured["enabled_toolsets"] == "UNSET", (
|
||||
f"Review fork narrowed the toolset schema (got {captured['enabled_toolsets']!r}), "
|
||||
"which breaks prefix-cache parity with the parent."
|
||||
assert captured["enabled_toolsets"] == agent.enabled_toolsets, (
|
||||
f"enabled_toolsets mismatch: {captured['enabled_toolsets']!r} "
|
||||
f"vs expected {agent.enabled_toolsets!r}"
|
||||
)
|
||||
assert captured["disabled_toolsets"] == agent.disabled_toolsets, (
|
||||
f"disabled_toolsets mismatch: {captured['disabled_toolsets']!r} "
|
||||
f"vs expected {agent.disabled_toolsets!r}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -19,11 +19,15 @@ Three distinct failure modes the user community hit during rollout:
|
||||
one-line hint pointing the user at https://grok.com and ``/model``.
|
||||
|
||||
3. Multi-turn replay of ``codex_reasoning_items`` (with
|
||||
``encrypted_content``) is now suppressed for ``is_xai_responses=True``
|
||||
in ``_chat_messages_to_responses_input``. xAI's OAuth/SuperGrok
|
||||
surface rejects replayed encrypted reasoning items; Grok still
|
||||
reasons natively each turn, so coherence rides on visible message
|
||||
text.
|
||||
``encrypted_content``) was briefly suppressed for ``is_xai_responses``
|
||||
in PR #26644 on the theory that xAI's OAuth/SuperGrok surface
|
||||
rejected replayed encrypted reasoning items. That suppression was
|
||||
reverted shortly after: xAI confirmed they explicitly want Hermes to
|
||||
thread encrypted reasoning back across turns, and the original
|
||||
multi-turn failure mode was actually the prelude-SSE issue closed by
|
||||
Fix A above. The remaining tests here lock in that xAI receives
|
||||
replayed reasoning AND that we ask xAI to echo it back in the
|
||||
``include`` array.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
@@ -353,8 +357,15 @@ def test_codex_reasoning_replay_default_includes_encrypted_content():
|
||||
assert reasoning[0]["encrypted_content"] == "enc_blob"
|
||||
|
||||
|
||||
def test_codex_reasoning_replay_stripped_for_xai_oauth():
|
||||
"""xAI OAuth surface must NOT receive replayed encrypted reasoning."""
|
||||
def test_codex_reasoning_replay_includes_encrypted_content_for_xai():
|
||||
"""xAI must receive replayed encrypted reasoning items (May 2026 reversal).
|
||||
|
||||
Earlier we stripped these on the theory that the OAuth/SuperGrok
|
||||
surface rejected them. xAI subsequently confirmed they explicitly
|
||||
want Hermes to thread encrypted reasoning back across turns for
|
||||
cross-turn coherence — that's the whole point of the partnership
|
||||
integration.
|
||||
"""
|
||||
from agent.codex_responses_adapter import _chat_messages_to_responses_input
|
||||
|
||||
msgs = [
|
||||
@@ -365,10 +376,13 @@ def test_codex_reasoning_replay_stripped_for_xai_oauth():
|
||||
|
||||
items = _chat_messages_to_responses_input(msgs, is_xai_responses=True)
|
||||
reasoning = [it for it in items if it.get("type") == "reasoning"]
|
||||
assert reasoning == []
|
||||
assert len(reasoning) == 1, (
|
||||
"xAI must receive replayed reasoning items — see docstring for the "
|
||||
"May 2026 reversal of the earlier suppression gate."
|
||||
)
|
||||
assert reasoning[0]["encrypted_content"] == "enc_blob"
|
||||
|
||||
# The assistant's visible text must still survive — coherence across
|
||||
# turns rides on the message text alone.
|
||||
# And the assistant's visible text must still be present alongside it.
|
||||
assistant_items = [
|
||||
it for it in items
|
||||
if it.get("role") == "assistant" or it.get("type") == "message"
|
||||
@@ -376,8 +390,12 @@ def test_codex_reasoning_replay_stripped_for_xai_oauth():
|
||||
assert assistant_items, "assistant message must still be present"
|
||||
|
||||
|
||||
def test_codex_transport_xai_request_omits_encrypted_content_include():
|
||||
"""Verify the xAI ``include`` array no longer requests encrypted reasoning."""
|
||||
def test_codex_transport_xai_request_includes_encrypted_content():
|
||||
"""xAI ``include`` array must request ``reasoning.encrypted_content``.
|
||||
|
||||
This is the request-side half of the May 2026 reversal: we ask xAI
|
||||
to echo back encrypted reasoning so the next turn can replay it.
|
||||
"""
|
||||
from agent.transports.codex import ResponsesApiTransport
|
||||
|
||||
transport = ResponsesApiTransport()
|
||||
@@ -392,14 +410,11 @@ def test_codex_transport_xai_request_omits_encrypted_content_include():
|
||||
reasoning_config={"enabled": True, "effort": "medium"},
|
||||
is_xai_responses=True,
|
||||
)
|
||||
# Without this gate, xAI would echo back encrypted_content blobs we'd
|
||||
# then store in codex_reasoning_items and replay next turn — which is
|
||||
# exactly the multi-turn failure mode we're closing.
|
||||
assert kwargs["include"] == []
|
||||
assert kwargs["include"] == ["reasoning.encrypted_content"]
|
||||
|
||||
|
||||
def test_codex_transport_xai_strips_replayed_reasoning_in_input():
|
||||
"""End-to-end: build_kwargs on xai-oauth must strip prior reasoning."""
|
||||
def test_codex_transport_xai_replays_reasoning_in_input():
|
||||
"""End-to-end: build_kwargs on xAI must replay prior encrypted reasoning."""
|
||||
from agent.transports.codex import ResponsesApiTransport
|
||||
|
||||
transport = ResponsesApiTransport()
|
||||
@@ -418,7 +433,8 @@ def test_codex_transport_xai_strips_replayed_reasoning_in_input():
|
||||
)
|
||||
input_items = kwargs["input"]
|
||||
reasoning_items = [it for it in input_items if it.get("type") == "reasoning"]
|
||||
assert reasoning_items == []
|
||||
assert len(reasoning_items) == 1
|
||||
assert reasoning_items[0]["encrypted_content"] == "enc_blob"
|
||||
|
||||
|
||||
def test_codex_transport_native_codex_still_replays_reasoning_in_input():
|
||||
|
||||
@@ -16,6 +16,7 @@ with ``APIConnectionError('Connection error.')`` whose cause was
|
||||
That is the exact scenario this test reproduces at object level without a
|
||||
network, so it runs in CI on every PR.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from run_agent import AIAgent
|
||||
@@ -186,3 +187,32 @@ def test_replace_primary_openai_client_survives_repeated_rebuilds():
|
||||
"Some _create_openai_client calls returned the same object across "
|
||||
"a teardown — rebuild is not producing fresh clients"
|
||||
)
|
||||
|
||||
|
||||
def test_force_close_tcp_sockets_descends_httpcore_1_connection_wrapper():
|
||||
"""httpcore 1.x stores the real stream below conn._connection."""
|
||||
from agent.agent_runtime_helpers import force_close_tcp_sockets
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self):
|
||||
self.shutdown_calls = 0
|
||||
self.close_calls = 0
|
||||
|
||||
def shutdown(self, _how):
|
||||
self.shutdown_calls += 1
|
||||
|
||||
def close(self):
|
||||
self.close_calls += 1
|
||||
|
||||
sock = FakeSocket()
|
||||
stream = SimpleNamespace(_sock=sock)
|
||||
http11 = SimpleNamespace(_network_stream=stream)
|
||||
pool_entry = SimpleNamespace(_connection=http11)
|
||||
pool = SimpleNamespace(_connections=[pool_entry])
|
||||
transport = SimpleNamespace(_pool=pool)
|
||||
http_client = SimpleNamespace(_transport=transport)
|
||||
openai_client = SimpleNamespace(_client=http_client)
|
||||
|
||||
assert force_close_tcp_sockets(openai_client) == 1
|
||||
assert sock.shutdown_calls == 1
|
||||
assert sock.close_calls == 1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -64,6 +65,7 @@ def _build_agent(shared_client=None):
|
||||
agent.stream_delta_callback = None
|
||||
agent._stream_callback = None
|
||||
agent.reasoning_callback = None
|
||||
agent.status_callback = None
|
||||
return agent
|
||||
|
||||
|
||||
@@ -93,6 +95,24 @@ def test_retry_after_api_connection_error_recreates_request_client(monkeypatch):
|
||||
assert second_request.close_calls >= 1
|
||||
|
||||
|
||||
def test_stale_non_stream_close_is_single_owner(monkeypatch):
|
||||
def slow_responder(**kwargs):
|
||||
time.sleep(0.1)
|
||||
raise _connection_error()
|
||||
|
||||
request_client = FakeRequestClient(slow_responder)
|
||||
factory = OpenAIFactory([request_client])
|
||||
monkeypatch.setattr(run_agent, "OpenAI", factory)
|
||||
|
||||
agent = _build_agent()
|
||||
agent._compute_non_stream_stale_timeout = lambda _messages: 0.01
|
||||
|
||||
with pytest.raises(APIConnectionError):
|
||||
agent._interruptible_api_call({"model": agent.model, "messages": []})
|
||||
|
||||
assert request_client.close_calls == 1
|
||||
|
||||
|
||||
def test_closed_shared_client_is_recreated_before_request(monkeypatch):
|
||||
stale_shared = FakeSharedClient(lambda **kwargs: (_ for _ in ()).throw(AssertionError("stale shared client used")))
|
||||
stale_shared._client.is_closed = True
|
||||
|
||||
@@ -168,3 +168,43 @@ class TestModelSupportsVision:
|
||||
agent = _make_agent()
|
||||
with patch("agent.models_dev.get_model_capabilities", side_effect=RuntimeError("boom")):
|
||||
assert agent._model_supports_vision() is False
|
||||
|
||||
def test_top_level_model_override_wins(self):
|
||||
agent = _make_agent()
|
||||
agent.provider = "custom"
|
||||
agent.model = "my-llava"
|
||||
with patch("hermes_cli.config.load_config", return_value={"model": {"supports_vision": True}}), \
|
||||
patch("agent.models_dev.get_model_capabilities", return_value=None):
|
||||
assert agent._model_supports_vision() is True
|
||||
|
||||
def test_per_provider_per_model_override_wins(self):
|
||||
agent = _make_agent()
|
||||
agent.provider = "custom"
|
||||
agent.model = "my-llava"
|
||||
cfg = {"providers": {"custom": {"models": {"my-llava": {"supports_vision": True}}}}}
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg), \
|
||||
patch("agent.models_dev.get_model_capabilities", return_value=None):
|
||||
assert agent._model_supports_vision() is True
|
||||
|
||||
def test_named_custom_provider_resolved_via_config_provider(self):
|
||||
# Named custom providers get runtime self.provider rewritten to
|
||||
# "custom" while the config keeps the original name under
|
||||
# model.provider. The override must still resolve.
|
||||
agent = _make_agent()
|
||||
agent.provider = "custom"
|
||||
agent.model = "my-llava"
|
||||
cfg = {
|
||||
"model": {"provider": "my-vllm", "default": "my-llava"},
|
||||
"providers": {"my-vllm": {"models": {"my-llava": {"supports_vision": True}}}},
|
||||
}
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg), \
|
||||
patch("agent.models_dev.get_model_capabilities", return_value=None):
|
||||
assert agent._model_supports_vision() is True
|
||||
|
||||
def test_override_false_disables_vision_for_models_dev_models(self):
|
||||
agent = _make_agent()
|
||||
fake_caps = MagicMock()
|
||||
fake_caps.supports_vision = True
|
||||
with patch("hermes_cli.config.load_config", return_value={"model": {"supports_vision": False}}), \
|
||||
patch("agent.models_dev.get_model_capabilities", return_value=fake_caps):
|
||||
assert agent._model_supports_vision() is False
|
||||
|
||||
Reference in New Issue
Block a user