Merge pull request #74383 from NousResearch/tests/prune-low-value

test: prune low-value tests suite-wide — 58% fewer tests, half the wall time, zero flakes
This commit is contained in:
Teknium 2026-07-29 15:46:36 -07:00 committed by GitHub
commit 92856bc28a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2093 changed files with 2139 additions and 377634 deletions

32
tests/acp/conftest.py Normal file
View file

@ -0,0 +1,32 @@
"""Shared fixtures for tests/acp.
Keeps the ACP server tests offline: ``HermesACPAgent._build_model_state``
calls ``hermes_cli.inventory.build_models_payload``, which (without this
fixture) performs live network fetches models.dev registry, GitHub model
catalog, Copilot token exchange, Anthropic model list adding ~3s of real
SSL/socket time to every test that creates or loads a session (~147s total
for test_server.py alone).
Tests that assert model-state behavior re-patch these same attributes with
``unittest.mock.patch`` / ``monkeypatch``; inner patches win, so this
default is transparent to them.
"""
import pytest
@pytest.fixture(autouse=True)
def _offline_model_inventory(monkeypatch):
"""Stub the shared model inventory so ACP tests never hit the network."""
import hermes_cli.inventory as inventory
class _StubPickerContext:
def with_overrides(self, **_kwargs):
return self
monkeypatch.setattr(inventory, "load_picker_context", lambda: _StubPickerContext())
monkeypatch.setattr(
inventory,
"build_models_payload",
lambda *_args, **_kwargs: {"providers": []},
)

View file

@ -15,6 +15,30 @@ Both fixed together by:
import threading
import pytest
@pytest.fixture(autouse=True)
def _isolate_approval_state(monkeypatch):
"""Keep these security regression tests hermetic.
Earlier tests (e.g. tests/acp/test_permissions.py) lazily load the
developer's real ``~/.hermes/config.yaml`` command allowlist into
``tools.approval._permanent_approved``. If that allowlist contains a
pattern like "recursive delete", ``rm -rf `` is auto-approved before
the interactive callback fires and the GHSA regression assertions fail
for reasons unrelated to the code under test.
"""
import tools.approval as _approval
monkeypatch.setattr(_approval, "_permanent_approved", set())
monkeypatch.setattr(_approval, "_session_approved", {})
# These tests assert the *manual* interactive-callback path. The default
# config is approvals.mode=smart, whose guardian LLM can auto-approve the
# command before the callback is consulted (test-order dependent, since
# load_config() caching decides which config file is in effect). Pin the
# mode so the GHSA regression path is what actually runs.
monkeypatch.setattr(_approval, "_get_approval_mode", lambda: "manual")
class TestThreadLocalApprovalCallback:
@ -93,27 +117,6 @@ class TestThreadLocalApprovalCallback:
# Main thread still has its callback
assert _get_approval_callback() is cb_main
def test_sudo_password_callback_also_thread_local(self):
"""Same protection applies to the sudo password callback."""
from tools.terminal_tool import (
set_sudo_password_callback,
_get_sudo_password_callback,
)
cb_main = lambda: "main-password" # noqa: E731
set_sudo_password_callback(cb_main)
worker_saw = []
def worker():
worker_saw.append(_get_sudo_password_callback())
t = threading.Thread(target=worker)
t.start()
t.join()
assert worker_saw == [None]
assert _get_sudo_password_callback() is cb_main
def test_sudo_password_cache_does_not_leak_across_threads(self):
"""Interactive sudo cache must not bleed into another executor thread."""
@ -138,58 +141,6 @@ class TestThreadLocalApprovalCallback:
assert worker_saw == [""]
assert _get_cached_sudo_password() == "main-thread-password"
def test_sudo_password_cache_isolated_across_acp_sessions_on_same_pool_thread(self):
"""ACP's ThreadPoolExecutor reuses threads. Two ACP sessions that land
on the same reused thread must not share the interactive sudo password
cache. The fix wraps each session in contextvars.copy_context() and
binds HERMES_SESSION_KEY per session, so the cache scope key differs
across sessions even when the underlying thread is identical.
"""
import contextvars
from concurrent.futures import ThreadPoolExecutor
from gateway.session_context import (
clear_session_vars,
set_session_vars,
)
from tools.terminal_tool import (
_get_cached_sudo_password,
_reset_cached_sudo_passwords,
_set_cached_sudo_password,
)
_reset_cached_sudo_passwords()
executor = ThreadPoolExecutor(max_workers=1) # force thread reuse
runs: list[tuple[str, str, str]] = [] # (session_id, before, after)
def _simulate_acp_session(session_id: str, write_password: str) -> None:
tokens = set_session_vars(session_key=session_id)
try:
observed_before = _get_cached_sudo_password()
_set_cached_sudo_password(write_password)
observed_after = _get_cached_sudo_password()
runs.append((session_id, observed_before, observed_after))
finally:
clear_session_vars(tokens)
def _run_in_fresh_context(session_id: str, pw: str) -> str:
ctx = contextvars.copy_context()
ctx.run(_simulate_acp_session, session_id, pw)
return session_id
try:
executor.submit(_run_in_fresh_context, "acp-session-A", "alpha-secret").result()
# Same thread. Without the fix B would see "alpha-secret".
executor.submit(_run_in_fresh_context, "acp-session-B", "bravo-secret").result()
finally:
executor.shutdown(wait=True)
_reset_cached_sudo_passwords()
assert runs[0] == ("acp-session-A", "", "alpha-secret")
# Core regression guard: B on the same reused thread must see an empty
# cache, not A's password.
assert runs[1] == ("acp-session-B", "", "bravo-secret")
class TestAcpExecAskGate:
@ -242,45 +193,3 @@ class TestAcpExecAskGate:
)
assert result["approved"] is True
def test_interactive_context_var_routes_to_callback_without_env(
self, monkeypatch,
):
"""Context-local interactive flag must work without touching os.environ.
Concurrent ACP sessions run on a shared ThreadPoolExecutor, so the
interactive flag is now a contextvar instead of a process-global env
var one session can no longer clobber another's flag mid-run
(GHSA-96vc-wcxf-jjff).
"""
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
from tools.approval import (
check_all_command_guards,
reset_hermes_interactive_context,
set_hermes_interactive_context,
)
called_with = []
def fake_cb(command, description, *, allow_permanent=True):
called_with.append((command, description))
return "once"
tok = set_hermes_interactive_context(True)
try:
result = check_all_command_guards(
"rm -rf /tmp/test-context-interactive",
"local",
approval_callback=fake_cb,
)
finally:
reset_hermes_interactive_context(tok)
assert called_with, (
"set_hermes_interactive_context(True) should route dangerous "
"commands through the callback without HERMES_INTERACTIVE in env"
)
assert result["approved"] is True

View file

@ -16,19 +16,7 @@ class TestHasProvider:
)
assert has_provider() is True
def test_has_no_provider_when_runtime_has_no_key(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda: {"provider": "openrouter", "api_key": ""},
)
assert has_provider() is False
def test_has_no_provider_when_runtime_resolution_fails(self, monkeypatch):
def _boom():
raise RuntimeError("no provider")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom)
assert has_provider() is False
class TestDetectProvider:
@ -39,33 +27,9 @@ class TestDetectProvider:
)
assert detect_provider() == "openrouter"
def test_detect_anthropic(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda: {"provider": "anthropic", "api_key": "sk-ant-test"},
)
assert detect_provider() == "anthropic"
def test_detect_none_when_no_key(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda: {"provider": "kimi-coding", "api_key": ""},
)
assert detect_provider() is None
def test_detect_none_on_resolution_error(self, monkeypatch):
def _boom():
raise RuntimeError("broken")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom)
assert detect_provider() is None
def test_detect_provider_strips_and_lowercases_provider(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda: {"provider": " OpenRouter ", "api_key": " sk-or-test "},
)
assert detect_provider() == "openrouter"
class TestBuildAuthMethods:
@ -82,21 +46,3 @@ class TestBuildAuthMethods:
assert terminal["type"] == "terminal"
assert terminal["args"] == ["--setup"]
def test_build_auth_methods_returns_terminal_setup_when_unconfigured(self, monkeypatch):
monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: None)
methods = build_auth_methods()
payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in methods]
assert payloads == [
{
"args": ["--setup"],
"description": (
"Open Hermes' interactive model/provider setup in a terminal. "
"Use this when Hermes has not been configured on this machine yet."
),
"id": TERMINAL_SETUP_AUTH_METHOD_ID,
"name": "Configure Hermes provider",
"type": "terminal",
}
]

View file

@ -41,72 +41,10 @@ def test_acp_permission_tool_call_uses_edit_kind_and_diff_content():
assert diff.newText == "new\n"
def test_write_file_rejection_does_not_mutate_existing_file(tmp_path):
target = tmp_path / "sample.txt"
target.write_text("before\n", encoding="utf-8")
set_edit_approval_requester(lambda _proposal: False)
result = json.loads(
handle_function_call(
"write_file",
{"path": str(target), "content": "after\n"},
task_id="acp-edit-reject",
)
)
assert "error" in result
assert "Edit approval denied" in result["error"]
assert target.read_text(encoding="utf-8") == "before\n"
def test_write_file_approval_mutates_and_request_includes_diff(tmp_path):
target = tmp_path / "sample.txt"
target.write_text("before\n", encoding="utf-8")
proposals = []
def approve(proposal):
proposals.append(proposal)
return True
set_edit_approval_requester(approve)
result = json.loads(
handle_function_call(
"write_file",
{"path": str(target), "content": "after\n"},
task_id="acp-edit-approve",
)
)
assert result.get("bytes_written") == len("after\n")
assert target.read_text(encoding="utf-8") == "after\n"
assert len(proposals) == 1
proposal = proposals[0]
assert proposal.tool_name == "write_file"
assert proposal.path == str(target)
assert proposal.old_text == "before\n"
assert proposal.new_text == "after\n"
def test_write_file_new_file_request_has_empty_old_text(tmp_path):
target = tmp_path / "new.txt"
proposals = []
set_edit_approval_requester(lambda proposal: proposals.append(proposal) or True)
result = json.loads(
handle_function_call(
"write_file",
{"path": str(target), "content": "created\n"},
task_id="acp-edit-new-file",
)
)
assert result.get("bytes_written") == len("created\n")
assert target.read_text(encoding="utf-8") == "created\n"
assert proposals[0].old_text is None
assert proposals[0].new_text == "created\n"
def test_requester_exception_denies_and_does_not_mutate(tmp_path):
@ -155,93 +93,10 @@ def test_patch_replace_rejection_does_not_mutate(tmp_path):
assert target.read_text(encoding="utf-8") == "alpha\nbeta\n"
def test_patch_v4a_rejection_does_not_mutate(tmp_path):
target = tmp_path / "sample.txt"
target.write_text("alpha\nbeta\n", encoding="utf-8")
set_edit_approval_requester(lambda _proposal: False)
result = json.loads(
handle_function_call(
"patch",
{
"mode": "patch",
"patch": (
"*** Begin Patch\n"
f"*** Update File: {target}\n"
"@@\n"
" alpha\n"
"-beta\n"
"+gamma\n"
"*** End Patch\n"
),
},
task_id="acp-patch-v4a-reject",
)
)
assert "error" in result
assert "Edit approval denied" in result["error"]
assert target.read_text(encoding="utf-8") == "alpha\nbeta\n"
def test_patch_v4a_approval_request_includes_patch_targets(tmp_path):
target = tmp_path / "sample.txt"
target.write_text("alpha\nbeta\n", encoding="utf-8")
proposals = []
set_edit_approval_requester(lambda proposal: proposals.append(proposal) or False)
json.loads(
handle_function_call(
"patch",
{
"mode": "patch",
"patch": (
"*** Begin Patch\n"
f"*** Update File: {target}\n"
"@@\n"
" alpha\n"
"-beta\n"
"+gamma\n"
"*** End Patch\n"
),
},
task_id="acp-patch-v4a-proposal",
)
)
assert len(proposals) == 1
assert proposals[0].tool_name == "patch"
assert proposals[0].path == str(target)
assert str(target) in proposals[0].new_text
def test_patch_replace_approval_request_includes_full_file_diff(tmp_path):
target = tmp_path / "sample.txt"
target.write_text("alpha\nbeta\n", encoding="utf-8")
proposals = []
set_edit_approval_requester(lambda proposal: proposals.append(proposal) or True)
result = json.loads(
handle_function_call(
"patch",
{
"mode": "replace",
"path": str(target),
"old_string": "beta\n",
"new_string": "gamma\n",
},
task_id="acp-patch-approve",
)
)
assert result.get("success") is True
assert target.read_text(encoding="utf-8") == "alpha\ngamma\n"
assert proposals[0].tool_name == "patch"
assert proposals[0].old_text == "alpha\nbeta\n"
assert proposals[0].new_text == "alpha\ngamma\n"
def test_workspace_auto_approval_allows_workspace_and_tmp_but_not_sensitive(tmp_path):

View file

@ -43,63 +43,12 @@ def test_main_skips_configured_mcp_discovery_when_requested(monkeypatch):
assert discovery_calls == []
@pytest.mark.parametrize("skip_value", [None, "", "0", "false"])
def test_main_discovers_configured_mcp_when_skip_is_not_enabled(monkeypatch, skip_value):
discovery_calls = []
async def fake_run_agent(agent, **kwargs):
pass
monkeypatch.setattr(entry, "_setup_logging", lambda: None)
monkeypatch.setattr(entry, "_load_env", lambda: None)
if skip_value is None:
monkeypatch.delenv("HERMES_ACP_SKIP_CONFIGURED_MCP", raising=False)
else:
monkeypatch.setenv("HERMES_ACP_SKIP_CONFIGURED_MCP", skip_value)
monkeypatch.setattr(
"tools.mcp_tool.discover_mcp_tools",
lambda: discovery_calls.append(True),
)
monkeypatch.setattr(acp, "run_agent", fake_run_agent)
entry.main([])
assert discovery_calls == [True]
def test_main_version_prints_without_starting_server(monkeypatch, capsys):
monkeypatch.setattr(entry, "_setup_logging", lambda: (_ for _ in ()).throw(AssertionError("started server")))
entry.main(["--version"])
output = capsys.readouterr().out.strip()
assert output
assert "Starting hermes-agent ACP adapter" not in output
def test_main_check_prints_ok_without_starting_server(monkeypatch, capsys):
monkeypatch.setattr(entry, "_setup_logging", lambda: (_ for _ in ()).throw(AssertionError("started server")))
entry.main(["--check"])
assert capsys.readouterr().out.strip() == "Hermes ACP check OK"
def test_main_setup_runs_model_configuration(monkeypatch):
calls = {}
def fake_hermes_main():
calls["argv"] = sys.argv[:]
monkeypatch.setattr("hermes_cli.main.main", fake_hermes_main)
# Pretend stdin is not a TTY so the follow-up browser prompt is skipped.
# That keeps this test focused on the model-setup wiring; the
# browser-prompt path has its own test below.
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
entry.main(["--setup"])
assert calls["argv"][1:] == ["model"]
def test_main_setup_offers_browser_install_when_tty(monkeypatch):
@ -121,70 +70,12 @@ def test_main_setup_offers_browser_install_when_tty(monkeypatch):
assert bootstrap_calls == [False]
def test_main_setup_skips_browser_prompt_on_no(monkeypatch):
monkeypatch.setattr("hermes_cli.main.main", lambda: None)
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "")
called = []
monkeypatch.setattr(
entry,
"_run_setup_browser",
lambda assume_yes=False: called.append(assume_yes) or 0,
)
entry.main(["--setup"])
assert called == []
def test_main_setup_browser_calls_ensure_dependency(monkeypatch):
"""`hermes-acp --setup-browser` routes through dep_ensure.ensure_dependency."""
calls = []
def fake_ensure(dep, interactive=True):
calls.append((dep, interactive))
return True
monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure)
entry.main(["--setup-browser"])
assert ("node", True) in calls
assert ("browser", True) in calls
def test_main_setup_browser_forwards_yes_flag(monkeypatch):
"""--yes suppresses interactive prompts in ensure_dependency."""
calls = []
def fake_ensure(dep, interactive=True):
calls.append((dep, interactive))
return True
monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure)
entry.main(["--setup-browser", "--yes"])
assert ("node", False) in calls
assert ("browser", False) in calls
def test_main_setup_browser_stops_on_node_failure(monkeypatch):
"""If node install fails, browser install is not attempted."""
calls = []
def fake_ensure(dep, interactive=True):
calls.append(dep)
return dep != "node" # node fails
monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure)
with pytest.raises(SystemExit) as excinfo:
entry.main(["--setup-browser"])
assert excinfo.value.code == 1
assert "node" in calls
assert "browser" not in calls
def test_main_setup_browser_propagates_browser_failure(monkeypatch):

View file

@ -68,39 +68,7 @@ class TestToolProgressCallback:
# The coroutine should be conn.session_update
assert mock_conn.session_update.called or coro is not None
def test_handles_string_args(self, mock_conn, event_loop_fixture):
"""If args is a JSON string, it should be parsed."""
tool_call_ids = {}
tool_call_meta = {}
loop = event_loop_fixture
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb("tool.started", "read_file", "Reading /etc/hosts", '{"path": "/etc/hosts"}')
assert "read_file" in tool_call_ids
def test_handles_non_dict_args(self, mock_conn, event_loop_fixture):
"""If args is not a dict, it should be wrapped."""
tool_call_ids = {}
tool_call_meta = {}
loop = event_loop_fixture
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb("tool.started", "terminal", "$ echo hi", None)
assert "terminal" in tool_call_ids
def test_duplicate_same_name_tool_calls_use_fifo_ids(self, mock_conn, event_loop_fixture):
"""Multiple same-name tool calls should be tracked independently in order."""
@ -132,32 +100,6 @@ class TestToolProgressCallback:
# ---------------------------------------------------------------------------
class TestThinkingCallback:
def test_emits_thought_chunk(self, mock_conn, event_loop_fixture):
"""Thinking callback should emit AgentThoughtChunk."""
loop = event_loop_fixture
cb = make_thinking_cb(mock_conn, "session-1", loop)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb("Analyzing the code...")
mock_rcts.assert_called_once()
def test_ignores_empty_text(self, mock_conn, event_loop_fixture):
"""Empty text should not emit any update."""
loop = event_loop_fixture
cb = make_thinking_cb(mock_conn, "session-1", loop)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
cb("")
mock_rcts.assert_not_called()
# ---------------------------------------------------------------------------
@ -184,34 +126,7 @@ class TestStepCallback:
assert "terminal" not in tool_call_ids
mock_rcts.assert_called_once()
def test_ignores_untracked_tools(self, mock_conn, event_loop_fixture):
"""Tools not in tool_call_ids should be silently ignored."""
tool_call_ids = {}
loop = event_loop_fixture
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
cb(1, [{"name": "unknown_tool", "result": "ok"}])
mock_rcts.assert_not_called()
def test_handles_string_tool_info(self, mock_conn, event_loop_fixture):
"""Tool info as a string (just the name) should work."""
tool_call_ids = {"read_file": "tc-def456"}
loop = event_loop_fixture
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb(2, ["read_file"])
assert "read_file" not in tool_call_ids
mock_rcts.assert_called_once()
def test_result_passed_to_build_tool_complete(self, mock_conn, event_loop_fixture):
"""Tool result from prev_tools dict is forwarded to build_tool_complete."""
@ -235,49 +150,7 @@ class TestStepCallback:
"tc-xyz789", "terminal", result='{"output": "hello"}', function_args=None, snapshot=None
)
def test_none_result_passed_through(self, mock_conn, event_loop_fixture):
"""When result is None (e.g. first iteration), None is passed through."""
from collections import deque
tool_call_ids = {"web_search": deque(["tc-aaa"])}
loop = event_loop_fixture
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts, \
patch("acp_adapter.events.build_tool_complete") as mock_btc:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb(1, [{"name": "web_search", "result": None}])
mock_btc.assert_called_once_with("tc-aaa", "web_search", result=None, function_args=None, snapshot=None)
def test_step_callback_passes_arguments_and_snapshot(self, mock_conn, event_loop_fixture):
from collections import deque
tool_call_ids = {"write_file": deque(["tc-write"])}
tool_call_meta = {"tc-write": {"args": {"path": "fallback.txt"}, "snapshot": "snap"}}
loop = event_loop_fixture
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts, \
patch("acp_adapter.events.build_tool_complete") as mock_btc:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb(1, [{"name": "write_file", "result": '{"bytes_written": 23}', "arguments": {"path": "diff-test.txt"}}])
mock_btc.assert_called_once_with(
"tc-write",
"write_file",
result='{"bytes_written": 23}',
function_args={"path": "diff-test.txt"},
snapshot="snap",
)
def test_tool_progress_captures_snapshot_metadata(self, mock_conn, event_loop_fixture):
tool_call_ids = {}
@ -329,21 +202,7 @@ class TestStepCallback:
assert [entry.status for entry in plan.entries] == ["completed", "in_progress", "completed"]
assert [entry.priority for entry in plan.entries] == ["medium", "medium", "medium"]
def test_todo_plan_update_parses_json_with_trailing_hint(self):
result = '{"todos":[{"id":"ship","content":"Ship ACP plan","status":"pending"}]}\n\n[Hint: persisted]'
update = _build_plan_update_from_todo_result(result)
assert isinstance(update, AgentPlanUpdate)
assert [entry.content for entry in update.entries] == ["Ship ACP plan"]
assert [entry.status for entry in update.entries] == ["pending"]
def test_todo_plan_update_with_empty_todos_clears_plan(self):
update = _build_plan_update_from_todo_result('{"todos":[],"summary":{"total":0}}')
assert isinstance(update, AgentPlanUpdate)
assert update.session_update == "plan"
assert update.entries == []
# ---------------------------------------------------------------------------
@ -351,32 +210,6 @@ class TestStepCallback:
# ---------------------------------------------------------------------------
class TestMessageCallback:
def test_emits_agent_message_chunk(self, mock_conn, event_loop_fixture):
"""Message callback should emit AgentMessageChunk."""
loop = event_loop_fixture
cb = make_message_cb(mock_conn, "session-1", loop)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb("Here is your answer.")
mock_rcts.assert_called_once()
def test_ignores_empty_message(self, mock_conn, event_loop_fixture):
"""Empty text should not emit any update."""
loop = event_loop_fixture
cb = make_message_cb(mock_conn, "session-1", loop)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
cb("")
mock_rcts.assert_not_called()
# ---------------------------------------------------------------------------

View file

@ -194,55 +194,6 @@ class TestMcpRegistrationE2E:
assert update.content[0].type == "content"
assert "Approval prompt shows the diff" in update.content[0].content.text
@pytest.mark.asyncio
async def test_prompt_tool_results_paired_by_call_id(self, acp_agent, mock_manager):
"""The ToolCallUpdate's toolCallId must match the ToolCallStart's."""
resp = await acp_agent.new_session(cwd="/tmp")
session_id = resp.session_id
state = mock_manager.get_session(session_id)
mock_conn = MagicMock(spec=acp.Client)
mock_conn.session_update = AsyncMock()
mock_conn.request_permission = AsyncMock()
acp_agent._conn = mock_conn
def mock_run(user_message, conversation_history=None, task_id=None, **kwargs):
agent = state.agent
# Fire two tool calls
if agent.tool_progress_callback:
agent.tool_progress_callback("tool.started", "read_file", "read: /etc/hosts", {"path": "/etc/hosts"})
agent.tool_progress_callback("tool.started", "web_search", "web search: test", {"query": "test"})
if agent.step_callback:
agent.step_callback(1, [
{"name": "read_file", "result": '{"content": "127.0.0.1 localhost"}'},
{"name": "web_search", "result": '{"data": {"web": []}}'},
])
return {"final_response": "Done.", "messages": []}
state.agent.run_conversation = mock_run
prompt = [TextContentBlock(type="text", text="test")]
await acp_agent.prompt(prompt=prompt, session_id=session_id)
updates = []
for call in mock_conn.session_update.call_args_list:
update_arg = call[1].get("update") or call[0][1]
updates.append(update_arg)
starts = [u for u in updates if getattr(u, "session_update", None) == "tool_call"]
completions = [u for u in updates if getattr(u, "session_update", None) == "tool_call_update"]
assert len(starts) == 2, f"Expected 2 starts, got {len(starts)}"
assert len(completions) == 2, f"Expected 2 completions, got {len(completions)}"
# Each completion's toolCallId must match a start's toolCallId
start_ids = {s.tool_call_id for s in starts}
completion_ids = {c.tool_call_id for c in completions}
assert start_ids == completion_ids, (
f"IDs must match: starts={start_ids}, completions={completion_ids}"
)
class TestMcpSanitizationE2E:

View file

@ -30,32 +30,6 @@ def _cfg(providers=None, custom_providers=None):
class TestNamedCustomProviderCatalogs:
def test_declared_default_model_survives_failed_discovery(self, monkeypatch):
"""Endpoints without a /models route keep their declared models."""
monkeypatch.setenv("BEDROCK_MANTLE_API_KEY", "test-key")
cfg = _cfg(
providers={
"bedrock-mantle": {
"name": "AWS Bedrock Mantle",
"base_url": MANTLE_URL,
"key_env": "BEDROCK_MANTLE_API_KEY",
"api_mode": "codex_responses",
"default_model": "openai.gpt-5.5",
}
}
)
with patch("hermes_cli.config.load_config", return_value=cfg), patch(
"hermes_cli.models.fetch_api_models", return_value=None
):
catalogs = _named_custom_provider_catalogs()
assert catalogs == [
(
"custom:bedrock-mantle",
"AWS Bedrock Mantle",
[("openai.gpt-5.5", "")],
)
]
def test_live_discovery_extends_declared_models(self, monkeypatch):
monkeypatch.setenv("SOME_KEY", "k")
@ -80,25 +54,6 @@ class TestNamedCustomProviderCatalogs:
assert slug == "custom:relay"
assert [m for m, _ in models] == ["model-a", "model-b"]
def test_declared_models_dict_included(self, monkeypatch):
monkeypatch.setenv("SOME_KEY", "k")
cfg = _cfg(
providers={
"relay": {
"name": "Relay",
"base_url": "https://relay.example/v1",
"key_env": "SOME_KEY",
"default_model": "model-a",
"models": {"model-b": {}, "model-c": {}},
}
}
)
with patch("hermes_cli.config.load_config", return_value=cfg), patch(
"hermes_cli.models.fetch_api_models", return_value=None
):
catalogs = _named_custom_provider_catalogs()
assert [m for m, _ in catalogs[0][2]] == ["model-a", "model-b", "model-c"]
def test_disabled_provider_skipped(self, monkeypatch):
monkeypatch.setenv("SOME_KEY", "k")

View file

@ -106,53 +106,9 @@ class TestApprovalBridge:
assert first_kwargs["tool_call"].tool_call_id != second_kwargs["tool_call"].tool_call_id
def test_prompt_path_keeps_session_option_when_permanent_disabled(self):
result, kwargs, _, _, _ = _invoke_callback(
AllowedOutcome(option_id="allow_session", outcome="selected"),
allow_permanent=False,
use_prompt_path=True,
)
option_ids = [option.option_id for option in kwargs["options"]]
assert result == "session"
assert option_ids == ["allow_once", "allow_session", "deny", "deny_always"]
def test_smart_deny_prompt_only_offers_once_and_deny(self):
result, kwargs, _, _, _ = _invoke_callback(
AllowedOutcome(option_id="allow_once", outcome="selected"),
allow_permanent=False,
smart_denied=True,
use_prompt_path=True,
)
assert result == "once"
assert [option.option_id for option in kwargs["options"]] == [
"allow_once", "deny",
]
def test_smart_deny_rejects_disallowed_session_outcome(self):
result, kwargs, _, _, _ = _invoke_callback(
AllowedOutcome(option_id="allow_session", outcome="selected"),
smart_denied=True,
)
assert result == "deny"
assert [option.option_id for option in kwargs["options"]] == [
"allow_once", "deny",
]
def test_reject_always_outcome_denies_without_changing_policy(self):
result, kwargs, _, _, _ = _invoke_callback(
AllowedOutcome(option_id="deny_always", outcome="selected"),
use_prompt_path=True,
)
deny_always = [option for option in kwargs["options"] if option.option_id == "deny_always"]
assert result == "deny"
assert len(deny_always) == 1
assert deny_always[0].kind == "reject_always"
def test_allow_always_maps_correctly(self):
result, _, _, _, _ = _invoke_callback(
@ -162,14 +118,6 @@ class TestApprovalBridge:
assert result == "always"
def test_denied_and_unknown_outcomes_deny(self):
denied_result, _, _, _, _ = _invoke_callback(DeniedOutcome(outcome="cancelled"))
unknown_result, _, _, _, _ = _invoke_callback(
AllowedOutcome(option_id="unexpected", outcome="selected"),
)
assert denied_result == "deny"
assert unknown_result == "deny"
def test_timeout_returns_deny_and_cancels_future(self):
loop = MagicMock(spec=asyncio.AbstractEventLoop)
@ -194,27 +142,6 @@ class TestApprovalBridge:
assert scheduled["loop"] is loop
assert future.cancel.call_count == 1
def test_none_response_returns_deny(self):
"""When request_permission resolves to None, the callback returns 'deny'."""
loop = MagicMock(spec=asyncio.AbstractEventLoop)
request_permission = AsyncMock(name="request_permission")
future = MagicMock(spec=Future)
future.result.return_value = None
scheduled = {}
def _schedule(coro, passed_loop):
scheduled["coro"] = coro
scheduled["loop"] = passed_loop
return future
with patch("agent.async_utils.asyncio.run_coroutine_threadsafe", side_effect=_schedule):
cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=1.0)
result = cb("echo hi", "demo")
scheduled["coro"].close()
assert result == "deny"
# ---------------------------------------------------------------------------

View file

@ -52,18 +52,8 @@ def test_filter_suppresses_benign_probe(method: str) -> None:
assert f.filter(record) is False
def test_filter_allows_real_method_not_found() -> None:
f = _BenignProbeMethodFilter()
exc = _bake_tb(RequestError.method_not_found("session/custom"))
record = _make_record("Background task failed", exc)
assert f.filter(record) is True
def test_filter_allows_non_request_error() -> None:
f = _BenignProbeMethodFilter()
exc = _bake_tb(RuntimeError("boom"))
record = _make_record("Background task failed", exc)
assert f.filter(record) is True
def test_filter_allows_different_message_even_for_ping() -> None:
@ -74,17 +64,8 @@ def test_filter_allows_different_message_even_for_ping() -> None:
assert f.filter(record) is True
def test_filter_allows_request_error_with_different_code() -> None:
f = _BenignProbeMethodFilter()
exc = _bake_tb(RequestError.invalid_params({"method": "ping"}))
record = _make_record("Background task failed", exc)
assert f.filter(record) is True
def test_filter_allows_log_without_exc_info() -> None:
f = _BenignProbeMethodFilter()
record = _make_record("Background task failed", None)
assert f.filter(record) is True
# -- End-to-end: drive a real JSON-RPC `ping` through acp.run_agent ---------

File diff suppressed because it is too large Load diff

View file

@ -37,11 +37,6 @@ class TestCreateSession:
assert state.history == []
assert state.agent is not None
def test_create_session_registers_task_cwd(self, manager, monkeypatch):
calls = []
monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda task_id, cwd: calls.append((task_id, cwd)))
state = manager.create_session(cwd="/tmp/work")
assert calls == [(state.session_id, "/tmp/work")]
def test_register_task_cwd_translates_windows_drive_for_wsl_tools(self, monkeypatch):
@ -64,18 +59,12 @@ class TestCreateSession:
"overrides": {"cwd": "/mnt/e/Projects/AI/paperclip"},
}
def test_session_ids_are_unique(self, manager):
s1 = manager.create_session()
s2 = manager.create_session()
assert s1.session_id != s2.session_id
def test_get_session(self, manager):
state = manager.create_session()
fetched = manager.get_session(state.session_id)
assert fetched is state
def test_get_nonexistent_session_returns_none(self, manager):
assert manager.get_session("does-not-exist") is None
def test_make_agent_stamps_session_cwd_for_codex_runtime(self, monkeypatch):
class FakeAgent:
@ -135,27 +124,9 @@ class TestWslCwdTranslation:
assert acp_session._translate_acp_cwd(r"E:\Projects\AI\paperclip") == "/mnt/e/Projects/AI/paperclip"
def test_translate_acp_cwd_handles_forward_slashes_when_wsl(self, monkeypatch):
monkeypatch.setattr("hermes_constants._wsl_detected", True)
assert acp_session._translate_acp_cwd("D:/work/project") == "/mnt/d/work/project"
def test_translate_acp_cwd_leaves_windows_drive_path_unchanged_off_wsl(self, monkeypatch):
monkeypatch.setattr("hermes_constants._wsl_detected", False)
assert acp_session._translate_acp_cwd(r"E:\Projects\AI\paperclip") == r"E:\Projects\AI\paperclip"
def test_translate_acp_cwd_leaves_posix_path_unchanged_on_wsl(self, monkeypatch):
monkeypatch.setattr("hermes_constants._wsl_detected", True)
assert acp_session._translate_acp_cwd("/mnt/e/Projects/AI/paperclip") == "/mnt/e/Projects/AI/paperclip"
def test_create_session_stores_translated_cwd_on_wsl(self, manager, monkeypatch):
monkeypatch.setattr("hermes_constants._wsl_detected", True)
state = manager.create_session(cwd=r"E:\Projects\AI\paperclip")
assert state.cwd == "/mnt/e/Projects/AI/paperclip"
def test_fork_session_stores_translated_cwd_on_wsl(self, manager, monkeypatch):
monkeypatch.setattr("hermes_constants._wsl_detected", True)
@ -180,32 +151,6 @@ class TestWslCwdTranslation:
# ---------------------------------------------------------------------------
class TestForkSession:
def test_fork_session_deep_copies_history(self, manager):
original = manager.create_session()
original.history.append({"role": "user", "content": "hello"})
original.history.append({"role": "assistant", "content": "hi"})
forked = manager.fork_session(original.session_id, cwd="/new")
assert forked is not None
# History should be equal in content
assert len(forked.history) == 2
assert forked.history[0]["content"] == "hello"
# But a deep copy — mutating one doesn't affect the other
forked.history.append({"role": "user", "content": "extra"})
assert len(original.history) == 2
assert len(forked.history) == 3
def test_fork_session_has_new_id(self, manager):
original = manager.create_session()
forked = manager.fork_session(original.session_id)
assert forked is not None
assert forked.session_id != original.session_id
def test_fork_nonexistent_returns_none(self, manager):
assert manager.fork_session("bogus-id") is None
# ---------------------------------------------------------------------------
@ -217,20 +162,7 @@ class TestListAndCleanup:
def test_list_sessions_empty(self, manager):
assert manager.list_sessions() == []
def test_list_sessions_returns_created(self, manager):
s1 = manager.create_session(cwd="/a")
s2 = manager.create_session(cwd="/b")
s1.history.append({"role": "user", "content": "hello from a"})
s2.history.append({"role": "user", "content": "hello from b"})
listing = manager.list_sessions()
ids = {s["session_id"] for s in listing}
assert s1.session_id in ids
assert s2.session_id in ids
assert len(listing) == 2
def test_list_sessions_hides_empty_threads(self, manager):
manager.create_session(cwd="/empty")
assert manager.list_sessions() == []
def test_save_session_preserves_existing_messages_on_encode_failure(self, manager):
"""Regression for #13675: a bad message in state.history must not
@ -260,123 +192,8 @@ class TestListAndCleanup:
assert messages[0]["content"] == "original"
assert isinstance(messages[0].get("timestamp"), (int, float))
def test_save_session_preserves_agent_archived_history(self, tmp_path):
"""Regression: ACP _persist must not destroy compression-archived rows.
When the agent owns persistence to the same SessionDB, it has already
flushed the transcript itself and used archive_and_compact() to keep
pre-compaction turns as searchable active=0/compacted=1 rows. A blind
replace_messages() here used to DELETE those archived rows (and the FTS
index entries with them) on every save silent data loss for any ACP
conversation long enough to compress.
"""
db = SessionDB(tmp_path / "state.db")
def factory():
# Mimic a live ACP agent: it persists to *this* db and has already
# created its session row / flushed at least one turn.
return SimpleNamespace(
model="test-model",
_session_db=db,
_session_db_created=True,
)
manager = SessionManager(agent_factory=factory, db=db)
state = manager.create_session(cwd="/work")
# Simulate the agent's own persistence: it flushed the live transcript,
# then compression archived the pre-compaction turns and inserted a
# compacted summary as the new active set.
db.append_message(
session_id=state.session_id, role="user", content="archived needle"
)
db.archive_and_compact(
state.session_id, [{"role": "user", "content": "compacted summary"}]
)
# ACP's in-memory history only tracks the post-compaction (active) set.
state.history = [{"role": "user", "content": "compacted summary"}]
manager.save_session(state.session_id)
# The archived pre-compaction turn must survive and stay discoverable.
contents = [
m["content"]
for m in db.get_messages(state.session_id, include_inactive=True)
]
assert "archived needle" in contents
assert "compacted summary" in contents
hits = {r["session_id"] for r in db.search_messages("needle")}
assert state.session_id in hits
def test_save_session_still_replaces_when_agent_not_self_persisting(self, manager):
"""Agents that don't own DB persistence keep ACP as the source of truth.
The default fixture's MagicMock agent has a ``_session_db`` that is *not*
the manager's db, so the destructive replace path stays active and ACP
history overwrites cleanly (no orphaned rows from a prior save).
"""
state = manager.create_session()
db = manager._get_db()
state.history = [{"role": "user", "content": "v1"}]
manager.save_session(state.session_id)
assert [
m["content"] for m in db.get_messages_as_conversation(state.session_id)
] == ["v1"]
state.history = [{"role": "user", "content": "v2 replaced"}]
manager.save_session(state.session_id)
assert [
m["content"] for m in db.get_messages_as_conversation(state.session_id)
] == ["v2 replaced"]
def test_save_session_preserves_archived_rows_on_model_switch(self, tmp_path):
"""Regression (#50405 W1/W2): a save by a fresh, non-self-persisting
agent must not destroy compaction-archived rows.
Model switches and /restore mint a brand-new agent with
``_session_db_created=False`` (so it does NOT "own" persistence) and
then immediately call save_session. If the session had already
compacted, a blind full-history replace would DELETE the archived
active=0/compacted=1 rows the same data loss the owned-agent guard
prevents. When archived rows exist, _persist must replace only the live
set (active_only) and leave the archived transcript intact.
"""
from types import SimpleNamespace
db = SessionDB(tmp_path / "state.db")
# Use a mock agent factory so create_session doesn't spin up a real
# AIAgent (which needs credentials and leaks provider-probe state across
# xdist workers). The factory's agent does NOT own persistence to db.
manager = SessionManager(
agent_factory=lambda: SimpleNamespace(model="m"), db=db
)
state = manager.create_session(cwd="/work")
# Session flushed a live turn, then compaction archived it.
db.append_message(
session_id=state.session_id, role="user", content="archived needle"
)
db.archive_and_compact(
state.session_id, [{"role": "user", "content": "compacted summary"}]
)
# Model switch: a fresh agent bound to THIS db but not yet self-created.
state.agent = SimpleNamespace(
model="new-model", _session_db=db, _session_db_created=False
)
state.history = [{"role": "user", "content": "compacted summary"}]
manager.save_session(state.session_id)
# Archived pre-compaction turn survives and stays discoverable.
contents = [
m["content"]
for m in db.get_messages(state.session_id, include_inactive=True)
]
assert "archived needle" in contents
assert "compacted summary" in contents
hits = {r["session_id"] for r in db.search_messages("needle")}
assert state.session_id in hits
def test_cleanup_clears_all(self, manager):
s1 = manager.create_session()
@ -403,199 +220,18 @@ class TestListAndCleanup:
class TestPersistence:
"""Verify that sessions are persisted to SessionDB and can be restored."""
def test_create_session_includes_registered_mcp_toolsets(self, tmp_path, monkeypatch):
captured = {}
def fake_resolve_runtime_provider(requested=None, **kwargs):
return {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.example/v1",
"api_key": "***",
"command": None,
"args": [],
}
def fake_agent(**kwargs):
captured.update(kwargs)
return SimpleNamespace(model=kwargs.get("model"), enabled_toolsets=kwargs.get("enabled_toolsets"))
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {
"model": {"provider": "openrouter", "default": "test-model"},
"mcp_servers": {
"olympus": {"command": "python", "enabled": True},
"exa": {"url": "https://exa.ai/mcp"},
"disabled": {"command": "python", "enabled": False},
},
})
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
fake_resolve_runtime_provider,
)
db = SessionDB(tmp_path / "state.db")
with patch("run_agent.AIAgent", side_effect=fake_agent):
manager = SessionManager(db=db)
manager.create_session(cwd="/work")
assert captured["enabled_toolsets"] == ["hermes-acp", "mcp-olympus", "mcp-exa"]
def test_create_session_writes_to_db(self, manager):
state = manager.create_session(cwd="/project")
db = manager._get_db()
assert db is not None
row = db.get_session(state.session_id)
assert row is not None
assert row["source"] == "acp"
# cwd stored in model_config JSON
mc = json.loads(row["model_config"])
assert mc["cwd"] == "/project"
def test_get_session_restores_from_db(self, manager):
"""Simulate process restart: create session, drop from memory, get again."""
state = manager.create_session(cwd="/work")
state.history.append({"role": "user", "content": "hello"})
state.history.append({"role": "assistant", "content": "hi there"})
manager.save_session(state.session_id)
sid = state.session_id
# Drop from in-memory store (simulates process restart).
with manager._lock:
del manager._sessions[sid]
# get_session should transparently restore from DB.
restored = manager.get_session(sid)
assert restored is not None
assert restored.session_id == sid
assert restored.cwd == "/work"
assert len(restored.history) == 2
assert restored.history[0]["content"] == "hello"
assert restored.history[1]["content"] == "hi there"
# Agent should have been recreated.
assert restored.agent is not None
def test_save_session_updates_db(self, manager):
state = manager.create_session()
state.history.append({"role": "user", "content": "test"})
manager.save_session(state.session_id)
db = manager._get_db()
messages = db.get_messages_as_conversation(state.session_id)
assert len(messages) == 1
assert messages[0]["content"] == "test"
def test_remove_session_deletes_from_db(self, manager):
state = manager.create_session()
db = manager._get_db()
assert db.get_session(state.session_id) is not None
manager.remove_session(state.session_id)
assert db.get_session(state.session_id) is None
def test_cleanup_removes_all_from_db(self, manager):
s1 = manager.create_session()
s2 = manager.create_session()
db = manager._get_db()
assert db.get_session(s1.session_id) is not None
assert db.get_session(s2.session_id) is not None
manager.cleanup()
assert db.get_session(s1.session_id) is None
assert db.get_session(s2.session_id) is None
def test_list_sessions_includes_db_only(self, manager):
"""Sessions only in DB (not in memory) appear in list_sessions."""
state = manager.create_session(cwd="/db-only")
state.history.append({"role": "user", "content": "database only thread"})
manager.save_session(state.session_id)
sid = state.session_id
# Drop from memory.
with manager._lock:
del manager._sessions[sid]
listing = manager.list_sessions()
ids = {s["session_id"] for s in listing}
assert sid in ids
def test_list_sessions_filters_by_cwd(self, manager):
keep = manager.create_session(cwd="/keep")
drop = manager.create_session(cwd="/drop")
keep.history.append({"role": "user", "content": "keep me"})
drop.history.append({"role": "user", "content": "drop me"})
listing = manager.list_sessions(cwd="/keep")
ids = {s["session_id"] for s in listing}
assert keep.session_id in ids
assert drop.session_id not in ids
def test_list_sessions_matches_windows_and_wsl_paths(self, manager):
state = manager.create_session(cwd="/mnt/e/Projects/AI/browser-link-3")
state.history.append({"role": "user", "content": "same project from WSL"})
listing = manager.list_sessions(cwd=r"E:\Projects\AI\browser-link-3")
ids = {s["session_id"] for s in listing}
assert state.session_id in ids
def test_list_sessions_prefers_title_then_preview(self, manager):
state = manager.create_session(cwd="/named")
state.history.append({"role": "user", "content": "Investigate broken ACP history in Zed"})
manager.save_session(state.session_id)
db = manager._get_db()
db.set_session_title(state.session_id, "Fix Zed ACP history")
listing = manager.list_sessions(cwd="/named")
assert listing[0]["title"] == "Fix Zed ACP history"
db.set_session_title(state.session_id, "")
listing = manager.list_sessions(cwd="/named")
assert listing[0]["title"].startswith("Investigate broken ACP history")
def test_list_sessions_sorted_by_most_recent_activity(self, manager):
older = manager.create_session(cwd="/ordered")
older.history.append({"role": "user", "content": "older"})
manager.save_session(older.session_id)
time.sleep(0.02)
newer = manager.create_session(cwd="/ordered")
newer.history.append({"role": "user", "content": "newer"})
manager.save_session(newer.session_id)
listing = manager.list_sessions(cwd="/ordered")
assert [item["session_id"] for item in listing[:2]] == [newer.session_id, older.session_id]
assert listing[0]["updated_at"]
assert listing[1]["updated_at"]
def test_fork_restores_source_from_db(self, manager):
"""Forking a session that is only in DB should work."""
original = manager.create_session()
original.history.append({"role": "user", "content": "context"})
manager.save_session(original.session_id)
# Drop original from memory.
with manager._lock:
del manager._sessions[original.session_id]
forked = manager.fork_session(original.session_id, cwd="/fork")
assert forked is not None
assert len(forked.history) == 1
assert forked.history[0]["content"] == "context"
assert forked.session_id != original.session_id
def test_update_cwd_restores_from_db(self, manager):
state = manager.create_session(cwd="/old")
sid = state.session_id
with manager._lock:
del manager._sessions[sid]
updated = manager.update_cwd(sid, "/new")
assert updated is not None
assert updated.cwd == "/new"
# Should also be persisted in DB.
db = manager._get_db()
row = db.get_session(sid)
mc = json.loads(row["model_config"])
assert mc["cwd"] == "/new"
def test_only_restores_acp_sessions(self, manager):
"""get_session should not restore non-ACP sessions from DB."""
@ -618,32 +254,6 @@ class TestPersistence:
session_ids = {r["session_id"] for r in results}
assert state.session_id in session_ids
def test_tool_calls_persisted(self, manager):
"""Messages with tool_calls should round-trip through the DB."""
state = manager.create_session()
state.history.append({
"role": "assistant",
"content": None,
"tool_calls": [{"id": "tc_1", "type": "function",
"function": {"name": "terminal", "arguments": "{}"}}],
})
state.history.append({
"role": "tool",
"content": "output here",
"tool_call_id": "tc_1",
"name": "terminal",
})
manager.save_session(state.session_id)
# Drop from memory, restore from DB.
with manager._lock:
del manager._sessions[state.session_id]
restored = manager.get_session(state.session_id)
assert restored is not None
assert len(restored.history) == 2
assert restored.history[0].get("tool_calls") is not None
assert restored.history[1].get("tool_call_id") == "tc_1"
def test_assistant_reasoning_fields_persisted(self, manager):
"""ACP session restore should preserve assistant reasoning context."""
@ -680,52 +290,6 @@ class TestPersistence:
],
}]
def test_restore_preserves_persisted_provider_snapshot(self, tmp_path, monkeypatch):
"""Restored ACP sessions should keep their original runtime provider."""
runtime_choice = {"provider": "anthropic"}
def fake_resolve_runtime_provider(requested=None, **kwargs):
provider = requested or runtime_choice["provider"]
return {
"provider": provider,
"api_mode": "anthropic_messages" if provider == "anthropic" else "chat_completions",
"base_url": f"https://{provider}.example/v1",
"api_key": f"{provider}-key",
"command": None,
"args": [],
}
def fake_agent(**kwargs):
return SimpleNamespace(
model=kwargs.get("model"),
provider=kwargs.get("provider"),
base_url=kwargs.get("base_url"),
api_mode=kwargs.get("api_mode"),
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {
"model": {"provider": runtime_choice["provider"], "default": "test-model"}
})
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
fake_resolve_runtime_provider,
)
db = SessionDB(tmp_path / "state.db")
with patch("run_agent.AIAgent", side_effect=fake_agent):
manager = SessionManager(db=db)
state = manager.create_session(cwd="/work")
manager.save_session(state.session_id)
with manager._lock:
del manager._sessions[state.session_id]
runtime_choice["provider"] = "openrouter"
restored = manager.get_session(state.session_id)
assert restored is not None
assert restored.agent.provider == "anthropic"
assert restored.agent.base_url == "https://anthropic.example/v1"
def test_acp_agents_route_human_output_to_stderr(self, tmp_path, monkeypatch):
"""ACP agents must keep stdout clean for JSON-RPC stdio transport."""

View file

@ -53,24 +53,7 @@ class TestUpdateSessionMeta:
assert stored["cwd"] == "/new/path"
assert stored["provider"] == "openai"
def test_updates_model_when_provided(self, tmp_path):
db = _tmp_db(tmp_path)
db.create_session("s2", source="acp", model="gpt-3.5")
db.update_session_meta("s2", json.dumps({"cwd": "."}), model="gpt-4o")
row = db.get_session("s2")
assert row["model"] == "gpt-4o"
def test_preserves_existing_model_when_none(self, tmp_path):
"""Passing model=None must leave the stored model unchanged (COALESCE)."""
db = _tmp_db(tmp_path)
db.create_session("s3", source="acp", model="claude-3")
db.update_session_meta("s3", json.dumps({"cwd": "."}), model=None)
row = db.get_session("s3")
assert row["model"] == "claude-3"
def test_uses_execute_write_not_private_api(self, tmp_path):
"""update_session_meta must route through _execute_write, not _conn directly."""
@ -91,10 +74,6 @@ class TestUpdateSessionMeta:
"update_session_meta must call _execute_write at least once"
)
def test_noop_on_nonexistent_session(self, tmp_path):
"""Updating a non-existent session must not raise."""
db = _tmp_db(tmp_path)
db.update_session_meta("ghost", json.dumps({"cwd": "."}), model=None)
# ---------------------------------------------------------------------------

View file

@ -55,17 +55,6 @@ def test_compression_split_continuation(db):
assert prov["creatorKind"] == "compression"
def test_multi_depth_chain(db):
_mk(db, "s0")
db.end_session("s0", "compression")
_mk(db, "s1", parent="s0")
db.end_session("s1", "compression")
_mk(db, "s2", parent="s1")
prov = build_session_provenance(db, "acp-1", "s2")
assert prov["rootHermesSessionId"] == "s0"
assert prov["compressionDepth"] == 2
assert prov["sessionKind"] == "continuation"
def test_non_compression_parent_is_root_not_continuation(db):
@ -79,20 +68,8 @@ def test_non_compression_parent_is_root_not_continuation(db):
assert prov["rootHermesSessionId"] == "p" # lineage root still walked
def test_no_false_rotation_when_head_unchanged(db):
_mk(db, "s")
# previous == current → no rotation reason emitted.
prov = build_session_provenance(
db, "acp-1", "s", previous_hermes_session_id="s"
)
assert "reason" not in prov
assert "creatorKind" not in prov
assert prov["previousHermesSessionId"] == "s"
def test_unknown_session_returns_none(db):
assert build_session_provenance(db, "acp-1", "does-not-exist") is None
assert session_provenance_meta(db, "acp-1", "does-not-exist") is None
def test_meta_wrapper_shape(db):

View file

@ -40,26 +40,12 @@ class TestToolKindMap:
def test_tool_kind_terminal(self):
assert get_tool_kind("terminal") == "execute"
def test_tool_kind_patch(self):
assert get_tool_kind("patch") == "edit"
def test_tool_kind_write_file(self):
assert get_tool_kind("write_file") == "edit"
def test_tool_kind_web_search(self):
assert get_tool_kind("web_search") == "fetch"
def test_tool_kind_execute_code(self):
assert get_tool_kind("execute_code") == "execute"
def test_tool_kind_todo(self):
assert get_tool_kind("todo") == "other"
def test_tool_kind_skill_view(self):
assert get_tool_kind("skill_view") == "read"
def test_tool_kind_browser_navigate(self):
assert get_tool_kind("browser_navigate") == "fetch"
def test_unknown_tool_returns_other_kind(self):
assert get_tool_kind("nonexistent_tool_xyz") == "other"
@ -104,48 +90,23 @@ class TestBuildToolTitle:
title = build_tool_title("read_file", {"path": "/etc/hosts"})
assert "/etc/hosts" in title
def test_patch_title(self):
title = build_tool_title("patch", {"path": "main.py", "mode": "replace"})
assert "main.py" in title
def test_search_title(self):
title = build_tool_title("search_files", {"pattern": "TODO"})
assert "TODO" in title
def test_web_search_title(self):
title = build_tool_title("web_search", {"query": "python asyncio"})
assert "python asyncio" in title
def test_web_extract_title_unwraps_search_result_object(self):
title = build_tool_title("web_extract", {
"urls": [
{"url": "https://example.com/a", "title": "A"},
{"href": "https://example.org/b"},
]
})
assert title == "extract: https://example.com/a (+1)"
def test_web_extract_title_handles_malformed_object(self):
assert build_tool_title("web_extract", {"urls": [{"title": "missing"}]}) == "extract: ?"
def test_skill_view_title_includes_skill_name(self):
title = build_tool_title("skill_view", {"name": "github-pitfalls"})
assert title == "skill view (github-pitfalls)"
def test_skill_view_title_includes_linked_file(self):
title = build_tool_title("skill_view", {"name": "github-pitfalls", "file_path": "references/api.md"})
assert title == "skill view (github-pitfalls/references/api.md)"
def test_execute_code_title_includes_first_code_line(self):
title = build_tool_title("execute_code", {"code": "\nfrom hermes_tools import terminal\nprint('done')"})
assert title == "python: from hermes_tools import terminal"
def test_skill_manage_title_includes_action_and_target(self):
title = build_tool_title(
"skill_manage",
{"action": "patch", "name": "hermes-agent-operations", "file_path": "references/acp.md"},
)
assert title == "skill patch: hermes-agent-operations/references/acp.md"
def test_unknown_tool_uses_name(self):
title = build_tool_title("some_new_tool", {"foo": "bar"})
@ -174,17 +135,6 @@ class TestBuildToolStart:
assert "Approval prompt shows the diff" in item.content.text
assert "src/main.py" in item.content.text
def test_build_tool_start_for_write_file(self):
"""write_file start should not duplicate the edit-approval diff."""
args = {"path": "new_file.py", "content": "print('hello')"}
result = build_tool_start("tc-w1", "write_file", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "edit"
assert len(result.content) >= 1
item = result.content[0]
assert isinstance(item, ContentToolCallContent)
assert "Approval prompt shows the diff" in item.content.text
assert "new_file.py" in item.content.text
def test_auto_approved_edit_start_shows_diff_content(self):
"""Auto-approved edit starts need the diff because no approval card exists."""
@ -205,57 +155,11 @@ class TestBuildToolStart:
assert item.old_text == "old\n"
assert item.new_text == "new\n"
def test_build_tool_start_for_terminal(self):
"""terminal should produce text content with the command."""
args = {"command": "ls -la /tmp"}
result = build_tool_start("tc-2", "terminal", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "execute"
assert len(result.content) >= 1
content_item = result.content[0]
assert isinstance(content_item, ContentToolCallContent)
# The wrapped text block should contain the command
text = content_item.content.text
assert "ls -la /tmp" in text
def test_build_tool_start_for_read_file(self):
"""read_file start should stay compact; completion carries file contents."""
args = {"path": "/etc/hosts", "offset": 1, "limit": 50}
result = build_tool_start("tc-3", "read_file", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "read"
assert result.content is None
assert result.raw_input is None
def test_build_tool_start_survives_non_string_command(self):
"""A malformed (non-string) terminal command previously raised
TypeError in build_tool_title (len(None)) and aborted the render."""
result = build_tool_start("tc-bad-cmd", "terminal", {"command": None})
assert isinstance(result, ToolCallStart)
assert result.kind == "execute" # tool identity preserved in the fallback
def test_build_tool_start_survives_non_string_path(self):
"""A non-string read_file path previously raised a ToolCallLocation
pydantic ValidationError in extract_locations and aborted the render."""
result = build_tool_start("tc-bad-path", "read_file", {"path": {"p": "x"}})
assert isinstance(result, ToolCallStart)
assert result.kind == "read"
def test_build_tool_start_survives_non_string_goal(self):
"""A non-string delegate_task goal previously raised TypeError
(len(123)) in build_tool_title and aborted the render."""
result = build_tool_start("tc-bad-goal", "delegate_task", {"goal": 123})
assert isinstance(result, ToolCallStart)
def test_build_tool_start_for_web_extract_is_compact(self):
"""web_extract start should stay compact; title identifies URLs."""
args = {"urls": ["https://example.com/docs"]}
result = build_tool_start("tc-web-start", "web_extract", args)
assert isinstance(result, ToolCallStart)
assert result.title == "extract: https://example.com/docs"
assert result.kind == "fetch"
assert result.content is None
assert result.raw_input is None
def test_build_tool_start_for_browser_navigate(self):
"""browser_navigate should emit a polished start event."""
@ -267,62 +171,11 @@ class TestBuildToolStart:
assert result.content[0].content.text == '{\n "url": "https://x.com"\n}'
assert result.raw_input is None
def test_build_tool_start_for_search(self):
"""search_files should include pattern in content."""
args = {"pattern": "TODO", "target": "content"}
result = build_tool_start("tc-4", "search_files", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "search"
assert "TODO" in result.content[0].content.text
assert result.raw_input is None
def test_build_tool_start_for_todo_is_human_readable(self):
args = {"todos": [{"id": "one", "content": "Fix ACP rendering", "status": "in_progress"}]}
result = build_tool_start("tc-todo", "todo", args)
assert result.title == "todo (1 item)"
assert "Fix ACP rendering" in result.content[0].content.text
assert result.raw_input is None
def test_build_tool_start_for_skill_view_is_human_readable(self):
result = build_tool_start("tc-skill", "skill_view", {"name": "github-pitfalls"})
assert result.title == "skill view (github-pitfalls)"
assert "github-pitfalls" in result.content[0].content.text
assert result.raw_input is None
def test_build_tool_start_for_execute_code_shows_code_preview(self):
result = build_tool_start("tc-code", "execute_code", {"code": "print('hello')"})
assert result.kind == "execute"
assert result.title == "python: print('hello')"
assert "```python" in result.content[0].content.text
assert "print('hello')" in result.content[0].content.text
assert result.raw_input is None
def test_build_tool_start_for_skill_manage_patch_shows_diff(self):
result = build_tool_start(
"tc-skill-manage",
"skill_manage",
{
"action": "patch",
"name": "hermes-agent-operations",
"file_path": "references/acp.md",
"old_string": "old advice",
"new_string": "new advice",
},
)
assert result.kind == "edit"
assert result.title == "skill patch: hermes-agent-operations/references/acp.md"
assert isinstance(result.content[0], FileEditToolCallContent)
assert result.content[0].path == "skills/hermes-agent-operations/references/acp.md"
assert result.content[0].old_text == "old advice"
assert result.content[0].new_text == "new advice"
assert result.raw_input is None
def test_build_tool_start_generic_fallback(self):
"""Unknown tools should get a generic text representation."""
args = {"foo": "bar", "baz": 42}
result = build_tool_start("tc-5", "some_tool", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "other"
# ---------------------------------------------------------------------------
@ -342,144 +195,23 @@ class TestBuildToolComplete:
assert "total 42" in content_item.content.text
assert result.raw_output is None
def test_build_tool_complete_for_todo_is_checklist(self):
result = build_tool_complete(
"tc-todo",
"todo",
'{"todos":[{"id":"a","content":"Inspect ACP","status":"completed"},{"id":"b","content":"Patch renderers","status":"in_progress"}],"summary":{"total":2,"pending":0,"in_progress":1,"completed":1,"cancelled":0}}',
)
text = result.content[0].content.text
assert "✅ Inspect ACP" in text
assert "- 🔄 Patch renderers" in text
assert "**Progress:** 1 completed, 1 in progress, 0 pending" in text
assert result.raw_output is None
def test_build_tool_complete_for_skill_view_summarizes_content_without_raw_json(self):
result = build_tool_complete(
"tc-skill",
"skill_view",
'{"success":true,"name":"github-pitfalls","description":"GitHub gotchas","content":"# GitHub Pitfalls\\nUse gh carefully.","path":"github/github-pitfalls/SKILL.md"}',
)
text = result.content[0].content.text
assert "**Skill loaded**" in text
assert "`github-pitfalls`" in text
assert "GitHub gotchas" in text
assert "GitHub Pitfalls" in text
assert "Use gh carefully" not in text
assert "Full skill content is available to the agent" in text
assert result.raw_output is None
def test_build_tool_complete_for_execute_code_formats_output(self):
result = build_tool_complete("tc-code", "execute_code", '{"output":"hello\\n","exit_code":0}')
text = result.content[0].content.text
assert "Exit code: 0" in text
assert "hello" in text
assert result.raw_output is None
def test_build_tool_complete_for_execute_code_shows_truncation_metadata(self):
result = build_tool_complete(
"tc-code-truncated",
"execute_code",
(
'{"output":"HEAD\\n... [OUTPUT TRUNCATED - 10 bytes omitted out of 60 total] ...\\nTAIL",'
'"exit_code":0,'
'"stdout_truncated":true,'
'"stdout_bytes_captured":50,'
'"stdout_bytes_total":60,'
'"stdout_bytes_omitted":10,'
'"warning":"execute_code stdout was truncated; the script did run."}'
),
)
text = result.content[0].content.text
assert "Exit code: 0" in text
assert "Output truncated: captured 50 of 60 bytes (10 omitted)." in text
assert "Warning:" in text
assert "the script did run" in text
assert result.raw_output is None
def test_build_tool_complete_marks_success_false_as_failed(self):
result = build_tool_complete("tc-fail", "skill_manage", '{"success": false, "error": "boom"}')
assert result.status == "failed"
def test_build_tool_complete_marks_ok_false_as_failed(self):
result = build_tool_complete("tc-fail", "some_tool", '{"ok": false, "error": "boom"}')
assert result.status == "failed"
def test_build_tool_complete_marks_exit_code_nonzero_as_failed(self):
result = build_tool_complete("tc-fail", "terminal", '{"output": "bad", "exit_code": 2}')
assert result.status == "failed"
def test_build_tool_complete_marks_returncode_nonzero_as_failed(self):
result = build_tool_complete("tc-fail", "execute_code", '{"output": "bad", "returncode": 2}')
assert result.status == "failed"
def test_build_tool_complete_keeps_plain_error_text_completed(self):
result = build_tool_complete("tc-ok", "terminal", "tests failed: 1 assertion error")
assert result.status == "completed"
def test_build_tool_complete_marks_raised_exception_prefix_as_failed(self):
"""The agent's tool executor wraps raised exceptions in a canonical
"Error executing tool '<name>': ..." prefix. That prefix is unique to
the wrapper and means the tool blew up, so it must surface as failed
in Zed regardless of whether the body parses as JSON.
"""
result = build_tool_complete(
"tc-fail-exc",
"patch",
"Error executing tool 'patch': KeyError: 'foo'",
)
assert result.status == "failed"
def test_build_tool_complete_does_not_match_error_word_alone(self):
"""Bare 'Error: ...' messages (without the unique 'Error executing
tool '<name>':' prefix) must still be reported as completed — they
legitimately appear in compiler/linter/test output.
"""
result = build_tool_complete(
"tc-ok-error-word",
"terminal",
"Error: pytest collected 0 items",
)
assert result.status == "completed"
def test_build_tool_complete_marks_structured_polished_tool_error_as_failed(self):
result = build_tool_complete("tc-fail", "read_file", '{"error": "File not found"}')
assert result.status == "failed"
def test_build_tool_complete_keeps_json_error_without_failure_flag_completed(self):
result = build_tool_complete("tc-ok", "some_tool", '{"error": "timeout while reading optional source"}')
assert result.status == "completed"
def test_build_tool_complete_for_skill_manage_summarizes_without_raw_json(self):
result = build_tool_complete(
"tc-skill-manage",
"skill_manage",
'{"success":true,"message":"Patched references/hermes-acp-zed-rendering.md in skill \'hermes-agent-operations\' (1 replacement)."}',
function_args={
"action": "patch",
"name": "hermes-agent-operations",
"file_path": "references/hermes-acp-zed-rendering.md",
},
)
text = result.content[0].content.text
assert "**✅ Skill updated**" in text
assert "`patch`" in text
assert "`hermes-agent-operations`" in text
assert "references/hermes-acp-zed-rendering.md" in text
assert "{\"success\"" not in text
assert result.raw_output is None
def test_build_tool_complete_for_read_file_formats_content(self):
result = build_tool_complete(
"tc-read",
"read_file",
'{"content":"1|hello\\n2|world","total_lines":2}',
function_args={"path":"README.md","offset":1,"limit":20},
)
text = result.content[0].content.text
assert "Read README.md" in text
assert "```\n1|hello\n2|world\n```" in text
assert result.raw_output is None
def test_build_tool_complete_for_search_files_formats_matches(self):
result = build_tool_complete(
@ -495,77 +227,11 @@ class TestBuildToolComplete:
assert "Results truncated" in text
assert result.raw_output is None
def test_build_tool_complete_for_process_list_formats_table(self):
result = build_tool_complete(
"tc-process",
"process",
'{"processes":[{"session_id":"p1","status":"running","pid":123,"command":"npm run dev"}]}',
function_args={"action":"list"},
)
text = result.content[0].content.text
assert "Processes: 1" in text
assert "`p1`" in text
assert "npm run dev" in text
assert result.raw_output is None
def test_build_tool_complete_for_delegate_task_summarizes_children(self):
result = build_tool_complete(
"tc-delegate",
"delegate_task",
'{"results":[{"task_index":0,"status":"completed","summary":"Reviewed ACP rendering.","model":"gpt-5.5","duration_seconds":3.2,"tool_trace":[{"tool":"read_file"}]}],"total_duration_seconds":3.4}',
)
text = result.content[0].content.text
assert "Delegation results: 1 task" in text
assert "Reviewed ACP rendering" in text
assert "gpt-5.5" in text
assert "Tools: read_file" in text
assert result.raw_output is None
def test_build_tool_complete_for_session_search_recent(self):
result = build_tool_complete(
"tc-session",
"session_search",
'{"success":true,"mode":"recent","results":[{"session_id":"s1","title":"ACP work","last_active":"2026-05-02","message_count":12,"preview":"Polished tool rendering."}],"count":1}',
)
text = result.content[0].content.text
assert "Recent sessions" in text
assert "ACP work" in text
assert "Polished tool rendering" in text
assert result.raw_output is None
def test_build_tool_complete_for_memory_avoids_dumping_entries(self):
result = build_tool_complete(
"tc-memory",
"memory",
'{"success":true,"target":"user","entries":["private long memory"],"usage":"1% — 19/2000 chars","entry_count":1,"message":"Entry added."}',
function_args={"action":"add","target":"user","content":"User likes concise ACP rendering."},
)
text = result.content[0].content.text
assert "Memory add saved" in text
assert "User likes concise ACP rendering" in text
assert "private long memory" not in text
assert result.raw_output is None
def test_build_tool_complete_for_web_extract_success_stays_compact(self):
result = build_tool_complete(
"tc-web-extract",
"web_extract",
'{"results":[{"url":"https://example.com","title":"Example","content":"# Intro\\nThis is extracted content."}]}',
)
assert result.content is None
assert result.raw_output is None
def test_build_tool_complete_for_web_extract_error_shows_error(self):
result = build_tool_complete(
"tc-web-extract-error",
"web_extract",
'{"results":[{"url":"https://example.com","title":"Example","error":"timeout"}]}',
)
text = result.content[0].content.text
assert "Web extract failed" in text
assert "https://example.com" in text
assert "timeout" in text
assert result.raw_output is None
def test_build_tool_complete_generically_formats_unknown_json_dict_without_raw_output(self):
result = build_tool_complete(
@ -580,95 +246,12 @@ class TestBuildToolComplete:
assert "{\"results\"" not in text
assert result.raw_output is None
def test_build_tool_complete_generically_formats_unknown_json_list_without_raw_output(self):
result = build_tool_complete(
"tc-plugin-list",
"some_plugin_tool",
'[{"name":"alpha","status":"ok"},{"name":"beta","status":"ok"}]',
)
text = result.content[0].content.text
assert "some_plugin_tool: 2 items" in text
assert "alpha" in text
assert result.raw_output is None
def test_build_tool_complete_generically_formats_nested_json_without_inline_blob(self):
result = build_tool_complete(
"tc-recall-stats",
"memory_archive_stats",
'{"observations_by_status":{"active":12,"rejected":83},"capabilities":["sqlite-fts5-archive","hash-chain-audit"],"audit":{"ok":true,"count":208,"head":"abc123"}}',
)
text = result.content[0].content.text
assert "**observations_by_status:**" in text
assert "**active:** 12" in text
assert "**rejected:** 83" in text
assert "**capabilities:** 2 items" in text
assert "sqlite-fts5-archive" in text
assert "**audit:**" in text
assert "**ok:** True" in text
assert "{\"active\"" not in text
assert "[\"sqlite" not in text
assert result.raw_output is None
def test_build_tool_complete_for_search_files_files_only_formats_file_list(self):
result = build_tool_complete(
"tc-search-files",
"search_files",
'{"total_count":36,"files":["/home/nour/.hermes/config.yaml","/home/nour/.hermes/profiles/recall-test/config.yaml"],"truncated":true}',
)
text = result.content[0].content.text
assert "File search results" in text
assert "Found 36 files; showing 2." in text
assert "/home/nour/.hermes/config.yaml" in text
assert "use offset to page" in text
assert "{\"total_count\"" not in text
assert result.raw_output is None
def test_build_tool_complete_truncates_large_output(self):
"""Very large outputs should be truncated."""
big_output = "x" * 10000
result = build_tool_complete("tc-6", "read_file", big_output)
assert isinstance(result, ToolCallProgress)
display_text = result.content[0].content.text
assert len(display_text) < 6000
assert "truncated" in display_text
def test_build_tool_complete_for_patch_summarizes_without_repeating_diff(self):
"""Completed patch calls should not duplicate the edit-approval diff."""
patch_result = (
'{"success": true, "diff": "--- a/README.md\\n+++ b/README.md\\n@@ -1 +1,2 @@\\n old line\\n+new line\\n", '
'"files_modified": ["README.md"]}'
)
result = build_tool_complete("tc-p1", "patch", patch_result)
assert isinstance(result, ToolCallProgress)
assert len(result.content) == 1
item = result.content[0]
assert isinstance(item, ContentToolCallContent)
assert "✅ patch completed" in item.content.text
assert "README.md" in item.content.text
def test_build_tool_complete_for_patch_falls_back_to_text_when_no_diff(self):
result = build_tool_complete("tc-p2", "patch", '{"success": true}')
assert isinstance(result, ToolCallProgress)
assert isinstance(result.content[0], ContentToolCallContent)
def test_build_tool_complete_for_write_file_summarizes_without_repeating_diff(self, tmp_path):
target = tmp_path / "diff-test.txt"
snapshot = type("Snapshot", (), {"paths": [target], "before": {str(target): None}})()
target.write_text("hello from hermes\n", encoding="utf-8")
result = build_tool_complete(
"tc-wf1",
"write_file",
'{"bytes_written": 18, "dirs_created": false}',
function_args={"path": str(target), "content": "hello from hermes\n"},
snapshot=snapshot,
)
assert isinstance(result, ToolCallProgress)
assert len(result.content) == 1
item = result.content[0]
assert isinstance(item, ContentToolCallContent)
assert "✅ write_file completed" in item.content.text
assert "diff-test.txt" in item.content.text
# ---------------------------------------------------------------------------

View file

@ -135,56 +135,10 @@ async def test_acp_steer_slash_command_injects_into_running_agent():
assert fake.runs == []
@pytest.mark.asyncio
async def test_acp_steer_after_zed_interrupt_replays_interrupted_prompt_with_guidance():
acp_agent, state, fake, _conn = make_agent_and_state()
state.interrupted_prompt_text = "write hi to a text file"
response = await acp_agent.prompt(
session_id=state.session_id,
prompt=[TextContentBlock(type="text", text="/steer write HELLO instead")],
)
assert response.stop_reason == "end_turn"
assert fake.steers == []
assert fake.runs == [
"write hi to a text file\n\nUser correction/guidance after interrupt: write HELLO instead"
]
assert state.interrupted_prompt_text == ""
@pytest.mark.asyncio
async def test_acp_plain_correction_redirects_running_turn():
acp_agent, state, fake, _conn = make_agent_and_state()
state.is_running = True
response = await acp_agent.prompt(
session_id=state.session_id,
prompt=[TextContentBlock(type="text", text="No, use Postgres instead")],
)
assert response.stop_reason == "end_turn"
assert fake.redirects == ["No, use Postgres instead"]
assert state.queued_prompts == []
assert fake.runs == []
@pytest.mark.asyncio
async def test_acp_plain_correction_after_cancel_replays_original_prompt():
acp_agent, state, fake, _conn = make_agent_and_state()
state.interrupted_prompt_text = "implement it with SQLite"
response = await acp_agent.prompt(
session_id=state.session_id,
prompt=[TextContentBlock(type="text", text="No, use Postgres instead")],
)
assert response.stop_reason == "end_turn"
assert fake.runs == [
"implement it with SQLite\n\n"
"User correction/guidance after interrupt: No, use Postgres instead"
]
assert state.interrupted_prompt_text == ""
@pytest.mark.asyncio
@ -209,52 +163,7 @@ async def test_acp_cancel_publishes_hard_stop_while_holding_runtime_lock():
assert state.interrupted_prompt_text == "original request"
@pytest.mark.asyncio
async def test_acp_steer_on_idle_session_runs_as_regular_prompt():
# /steer on an idle session (no running turn, nothing to salvage) should
# run the steer payload as a normal user prompt — NOT silently append it
# to state.queued_prompts. Without this, users on Zed / other ACP clients
# see their /steer turn into "queued for the next turn" when they never
# typed /queue. Matches gateway/run.py ~L4898 idle-/steer behavior.
acp_agent, state, fake, _conn = make_agent_and_state()
response = await acp_agent.prompt(
session_id=state.session_id,
prompt=[TextContentBlock(type="text", text="/steer summarize the README")],
)
assert response.stop_reason == "end_turn"
assert fake.steers == []
assert fake.runs == ["summarize the README"]
assert state.queued_prompts == []
@pytest.mark.asyncio
async def test_acp_queue_slash_command_adds_next_turn_without_running_now():
acp_agent, state, fake, _conn = make_agent_and_state()
response = await acp_agent.prompt(
session_id=state.session_id,
prompt=[TextContentBlock(type="text", text="/queue run the tests after this")],
)
assert response.stop_reason == "end_turn"
assert state.queued_prompts == ["run the tests after this"]
assert fake.runs == []
@pytest.mark.asyncio
async def test_acp_prompt_drains_queued_turns_after_current_run():
acp_agent, state, fake, conn = make_agent_and_state()
state.queued_prompts.append("then run tests")
response = await acp_agent.prompt(
session_id=state.session_id,
prompt=[TextContentBlock(type="text", text="make the change")],
)
assert response.stop_reason == "end_turn"
assert fake.runs == ["make the change", "then run tests"]
assert state.queued_prompts == []
agent_messages = [u for _sid, u in conn.updates if getattr(u, "session_update", None) == "agent_message_chunk"]
assert len(agent_messages) >= 2

View file

@ -59,23 +59,6 @@ def test_acp_resource_link_file_is_inlined_as_text(tmp_path):
)
def test_acp_embedded_text_resource_is_inlined_as_text():
content = _content_blocks_to_openai_user_content([
EmbeddedResourceContentBlock(
type="resource",
resource=TextResourceContents(
uri="file:///workspace/todo.txt",
mimeType="text/plain",
text="first\nsecond",
),
),
])
assert content == (
"[Attached file: todo.txt]\n"
"URI: file:///workspace/todo.txt\n\n"
"first\nsecond"
)
@pytest.mark.asyncio
@ -94,66 +77,7 @@ _ONE_PX_PNG = bytes.fromhex(
)
def test_acp_resource_link_image_file_is_inlined_as_image_url(tmp_path):
attached = tmp_path / "shot.png"
attached.write_bytes(_ONE_PX_PNG)
content = _content_blocks_to_openai_user_content([
TextContentBlock(type="text", text="Look at this screenshot"),
ResourceContentBlock(
type="resource_link",
name="shot.png",
uri=attached.as_uri(),
mimeType="image/png",
),
])
assert isinstance(content, list)
# [user text, image header, image_url]
assert content[0] == {"type": "text", "text": "Look at this screenshot"}
assert content[1]["type"] == "text"
assert "[Attached image: shot.png]" in content[1]["text"]
assert content[2]["type"] == "image_url"
expected_url = "data:image/png;base64," + base64.b64encode(_ONE_PX_PNG).decode("ascii")
assert content[2]["image_url"]["url"] == expected_url
def test_acp_resource_link_image_mime_inferred_from_suffix(tmp_path):
"""No mimeType sent — should still be recognised as image by file suffix."""
attached = tmp_path / "pic.jpg"
attached.write_bytes(_ONE_PX_PNG) # content doesn't matter for the code path
content = _content_blocks_to_openai_user_content([
ResourceContentBlock(
type="resource_link",
name="pic.jpg",
uri=attached.as_uri(),
),
])
assert isinstance(content, list)
image_parts = [p for p in content if p.get("type") == "image_url"]
assert len(image_parts) == 1
assert image_parts[0]["image_url"]["url"].startswith("data:image/jpeg;base64,")
def test_acp_embedded_blob_image_is_inlined_as_image_url():
b64 = base64.b64encode(_ONE_PX_PNG).decode("ascii")
content = _content_blocks_to_openai_user_content([
EmbeddedResourceContentBlock(
type="resource",
resource=BlobResourceContents(
uri="file:///tmp/embed.png",
mimeType="image/png",
blob=b64,
),
),
])
assert isinstance(content, list)
assert content[0]["type"] == "text"
assert "[Attached image: embed.png]" in content[0]["text"]
assert content[1] == {
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
}

View file

@ -63,25 +63,4 @@ class TestDetectProviderEntra:
):
assert _acp_auth.detect_provider() is None
def test_missing_provider_returns_none(self):
"""A callable api_key without a provider is still ``None`` —
we don't synthesize a provider name from the credential shape."""
from acp_adapter import auth as _acp_auth
def _fake_runtime(**_kwargs):
return {"api_key": lambda: "jwt-fresh", "provider": ""}
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=_fake_runtime,
):
assert _acp_auth.detect_provider() is None
def test_resolver_exception_returns_none(self):
from acp_adapter import auth as _acp_auth
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=RuntimeError("simulated"),
):
assert _acp_auth.detect_provider() is None

View file

@ -20,47 +20,10 @@ def _reset():
eventlog.reset_announce_caches()
def test_local_only_helper_returns_true_for_local_env():
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
fops = ShellFileOperations(LocalEnvironment(cwd="/tmp"))
assert fops._lsp_local_only() is True
def test_local_only_helper_returns_false_for_non_local_env():
"""A mocked non-local env (Docker/Modal/SSH stand-in) returns False."""
from tools.file_operations import ShellFileOperations
# Build something that's NOT a LocalEnvironment. We use a bare
# MagicMock — isinstance() against LocalEnvironment is False.
fake_env = MagicMock()
fake_env.execute = MagicMock(return_value=MagicMock(exit_code=0, stdout=""))
fake_env.cwd = "/sandbox"
fops = ShellFileOperations(fake_env)
assert fops._lsp_local_only() is False
def test_snapshot_baseline_skipped_for_non_local(monkeypatch):
"""Verify the LSP service's snapshot_baseline is NOT called when
the backend isn't local."""
from tools.file_operations import ShellFileOperations
fake_env = MagicMock()
fake_env.execute = MagicMock(return_value=MagicMock(exit_code=0, stdout=""))
fake_env.cwd = "/sandbox"
fops = ShellFileOperations(fake_env)
snapshot_called = []
class FakeService:
def snapshot_baseline(self, path):
snapshot_called.append(path)
monkeypatch.setattr("agent.lsp.get_service", lambda: FakeService())
fops._snapshot_lsp_baseline("/sandbox/x.py")
assert snapshot_called == [], "snapshot must be skipped for non-local backends"
def test_maybe_lsp_diagnostics_returns_empty_for_non_local(monkeypatch):

View file

@ -39,79 +39,10 @@ def _make_git_workspace(tmp_path: Path) -> Path:
return repo
def test_mark_broken_for_file_adds_correct_key(tmp_path, monkeypatch):
"""``_mark_broken_for_file`` keys the broken-set on
(server_id, per_server_root) so subsequent ``enabled_for`` calls
for files in the same project skip immediately."""
repo = _make_git_workspace(tmp_path)
monkeypatch.chdir(str(repo))
src = repo / "x.py"
src.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
svc._mark_broken_for_file(str(src), RuntimeError("simulated"))
# The pyright server resolves to the repo root via pyproject.toml.
assert ("pyright", str(repo)) in svc._broken
finally:
svc.shutdown()
def test_enabled_for_returns_false_after_broken(tmp_path, monkeypatch):
"""Once a (server_id, root) pair is in the broken-set,
``enabled_for`` returns False so the file_operations layer skips
the LSP path entirely."""
repo = _make_git_workspace(tmp_path)
monkeypatch.chdir(str(repo))
src = repo / "x.py"
src.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
# Initially enabled.
assert svc.enabled_for(str(src)) is True
# Mark broken.
svc._mark_broken_for_file(str(src), RuntimeError("simulated"))
# Now disabled — the broken-set short-circuits.
assert svc.enabled_for(str(src)) is False
finally:
svc.shutdown()
def test_enabled_for_other_file_in_same_project_also_skipped(tmp_path, monkeypatch):
"""The broken key is (server_id, root), so ALL files routed through
the same server in the same project are skipped not just the one
that triggered the failure."""
repo = _make_git_workspace(tmp_path)
monkeypatch.chdir(str(repo))
a = repo / "a.py"
a.write_text("")
b = repo / "b.py"
b.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
svc._mark_broken_for_file(str(a), RuntimeError("simulated"))
# Both files in the same project skip pyright now.
assert svc.enabled_for(str(a)) is False
assert svc.enabled_for(str(b)) is False
finally:
svc.shutdown()
def test_unrelated_project_not_affected_by_broken(tmp_path, monkeypatch):
@ -144,21 +75,6 @@ def test_unrelated_project_not_affected_by_broken(tmp_path, monkeypatch):
svc.shutdown()
def test_mark_broken_handles_missing_server_silently(tmp_path):
"""If the file extension doesn't match any registered server,
``_mark_broken_for_file`` no-ops nothing to mark."""
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
# No registered server for .xyz; must not raise.
svc._mark_broken_for_file(str(tmp_path / "weird.xyz"), RuntimeError("x"))
assert len(svc._broken) == 0
finally:
svc.shutdown()
def test_mark_broken_handles_no_workspace_silently(tmp_path):

View file

@ -72,72 +72,9 @@ async def test_client_receives_published_errors(tmp_path: Path):
await client.shutdown()
@pytest.mark.asyncio
async def test_client_didchange_bumps_version(tmp_path: Path):
f = tmp_path / "x.py"
f.write_text("print('hi')\n")
client = _client(tmp_path, "errors")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
f.write_text("print('hi 2')\n")
v1 = await client.open_file(str(f), language_id="python") # re-open path = didChange
assert v1 == v0 + 1
await client.wait_for_diagnostics(str(f), v1, mode="document")
# Mock pushed a diagnostic for both events; merged view has one
# entry (push store keyed by file path).
diags = client.diagnostics_for(str(f))
assert len(diags) == 1
finally:
await client.shutdown()
@pytest.mark.asyncio
async def test_client_handles_crashing_server(tmp_path: Path):
"""When the server exits right after initialize, subsequent requests
fail gracefully (not hang)."""
f = tmp_path / "x.py"
f.write_text("")
client = _client(tmp_path, "crash")
await client.start() # should succeed (mock answers initialize before crashing)
# Give the OS a moment to deliver the EOF.
await asyncio.sleep(0.2)
# The reader loop should detect EOF and mark pending requests as failed.
try:
await asyncio.wait_for(
client.open_file(str(f), language_id="python"), timeout=2.0
)
except Exception:
pass # any exception is acceptable; the contract is "doesn't hang"
await client.shutdown()
@pytest.mark.asyncio
async def test_client_shutdown_idempotent(tmp_path: Path):
"""Calling shutdown twice must be safe."""
f = tmp_path / "x.py"
f.write_text("")
client = _client(tmp_path, "clean")
await client.start()
await client.shutdown()
await client.shutdown() # must not raise
@pytest.mark.asyncio
async def test_client_diagnostics_are_deduped(tmp_path: Path):
"""Repeated identical pushes must not produce duplicate diagnostics."""
f = tmp_path / "x.py"
f.write_text("")
client = _client(tmp_path, "errors")
await client.start()
try:
for _ in range(3):
v = await client.open_file(str(f), language_id="python")
await client.wait_for_diagnostics(str(f), v, mode="document")
diags = client.diagnostics_for(str(f))
# Push store overwrites on every notification — should have 1.
assert len(diags) == 1
finally:
await client.shutdown()

View file

@ -49,14 +49,6 @@ def _diag(*, line: int, message: str = "Undefined variable",
# _diag_key: strict equality (with range)
# ----------------------------------------------------------------------
def test_diag_key_treats_shifted_diagnostics_as_distinct():
"""Two diagnostics with the same message but at different lines hash
differently they are genuinely different diagnostics. The shift
map is what makes them equal AFTER remapping; the key itself stays
strict."""
a = _diag(line=100)
b = _diag(line=200)
assert _diag_key(a) != _diag_key(b)
def test_diag_key_matches_client_key_for_shifted_baseline():
@ -74,22 +66,10 @@ def test_diag_key_matches_client_key_for_shifted_baseline():
assert _diag_key(shifted) == _diag_key(post)
def test_diag_key_distinguishes_message():
a = _diag(line=100, message="foo")
b = _diag(line=100, message="bar")
assert _diag_key(a) != _diag_key(b)
def test_diag_key_distinguishes_severity():
a = _diag(line=100, severity=1)
b = _diag(line=100, severity=2)
assert _diag_key(a) != _diag_key(b)
def test_diag_key_distinguishes_source():
a = _diag(line=100, source="Pyright")
b = _diag(line=100, source="Ruff")
assert _diag_key(a) != _diag_key(b)
def test_diag_key_matches_client_key_byte_for_byte():
@ -104,36 +84,10 @@ def test_diag_key_matches_client_key_byte_for_byte():
# build_line_shift
# ----------------------------------------------------------------------
def test_shift_identity_for_identical_content():
shift = build_line_shift("a\nb\nc\n", "a\nb\nc\n")
assert shift(0) == 0
assert shift(1) == 1
assert shift(2) == 2
def test_shift_pure_deletion_above_line():
"""Delete 2 lines at the top; everything below shifts up by 2."""
pre = "line0\nline1\nline2\nline3\nline4\n"
post = "line2\nline3\nline4\n" # deleted lines 0-1
shift = build_line_shift(pre, post)
# Pre lines 0,1 → deleted → None
assert shift(0) is None
assert shift(1) is None
# Pre line 2 → post line 0
assert shift(2) == 0
# Pre line 4 → post line 2
assert shift(4) == 2
def test_shift_pure_insertion_above_line():
"""Insert 3 lines at the top; everything below shifts down by 3."""
pre = "line0\nline1\nline2\n"
post = "new0\nnew1\nnew2\nline0\nline1\nline2\n"
shift = build_line_shift(pre, post)
# Pre lines unchanged in identity, shifted by 3
assert shift(0) == 3
assert shift(1) == 4
assert shift(2) == 5
def test_shift_replacement_in_middle():
@ -149,19 +103,8 @@ def test_shift_replacement_in_middle():
assert shift(4) == 3 # e → post line 3
def test_shift_handles_empty_pre():
"""First write of a file: pre is empty, post has content. Nothing
to shift, so the function should be well-defined for empty pre."""
shift = build_line_shift("", "hello\nworld\n")
# Any pre line falls past the end of an empty pre — anchor at end of post
assert shift(0) == 1
def test_shift_handles_empty_post():
"""File deleted to empty. Every pre line returns None."""
shift = build_line_shift("line0\nline1\n", "")
assert shift(0) is None
assert shift(1) is None
# ----------------------------------------------------------------------
@ -179,22 +122,8 @@ def test_shift_diag_remaps_start_and_end():
assert remapped["range"]["end"]["line"] == 3
def test_shift_diag_drops_diagnostic_in_deleted_region():
pre = "a\nb\nc\nd\n"
post = "a\nd\n" # deleted lines 1,2 (b,c)
shift = build_line_shift(pre, post)
d = _diag(line=1)
assert shift_diagnostic_range(d, shift) is None
def test_shift_diag_does_not_mutate_original():
pre = "a\nb\n"
post = "X\na\nb\n"
shift = build_line_shift(pre, post)
d = _diag(line=0)
original_line = d["range"]["start"]["line"]
_ = shift_diagnostic_range(d, shift)
assert d["range"]["start"]["line"] == original_line
def test_shift_baseline_drops_deleted_and_remaps_rest():
@ -217,25 +146,6 @@ def test_shift_baseline_drops_deleted_and_remaps_rest():
# End-to-end: simulate the delta-filter pipeline
# ----------------------------------------------------------------------
def test_pipeline_filters_shifted_baseline_under_strict_key():
"""The exact scenario the bug fix is for: an edit deletes lines,
every diagnostic below shifts, and the delta filter (strict key
+ shifted baseline) correctly identifies them as pre-existing."""
pre = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n"
# Delete lines 2,3,4 — pre-existing errors at lines 7,8 should
# appear at lines 4,5 post-edit and be filtered out.
post = "line0\nline1\nline5\nline6\nline7\nline8\nline9\n"
shift = build_line_shift(pre, post)
baseline = [_diag(line=7, message="X"), _diag(line=8, message="Y")]
post_diags = [_diag(line=4, message="X"), _diag(line=5, message="Y")]
shifted_baseline = shift_baseline(baseline, shift)
seen = {_diag_key(d) for d in shifted_baseline}
new_diags = [d for d in post_diags if _diag_key(d) not in seen]
# Both errors were pre-existing — filtered out.
assert new_diags == []
def test_pipeline_preserves_new_instance_at_different_line():

View file

@ -22,26 +22,12 @@ from tools.file_operations import (
# ---------------------------------------------------------------------------
def test_writeresult_lsp_diagnostics_optional():
r = WriteResult()
assert r.lsp_diagnostics is None
def test_writeresult_to_dict_omits_field_when_none():
r = WriteResult(bytes_written=10)
assert "lsp_diagnostics" not in r.to_dict()
def test_writeresult_to_dict_includes_field_when_set():
r = WriteResult(bytes_written=10, lsp_diagnostics="<diagnostics>...</diagnostics>")
d = r.to_dict()
assert d["lsp_diagnostics"] == "<diagnostics>...</diagnostics>"
def test_patchresult_to_dict_includes_field_when_set():
r = PatchResult(success=True, lsp_diagnostics="ERROR [1:1] thing")
d = r.to_dict()
assert d["lsp_diagnostics"] == "ERROR [1:1] thing"
def test_patchresult_to_dict_omits_field_when_none():
@ -49,10 +35,6 @@ def test_patchresult_to_dict_omits_field_when_none():
assert "lsp_diagnostics" not in r.to_dict()
def test_patchresult_to_dict_omits_field_when_empty_string():
"""Empty string counts as falsy — agent shouldn't see an empty field."""
r = PatchResult(success=True, lsp_diagnostics="")
assert "lsp_diagnostics" not in r.to_dict()
# ---------------------------------------------------------------------------
@ -80,31 +62,8 @@ def test_lint_and_lsp_diagnostics_are_separate_channels():
# ---------------------------------------------------------------------------
def test_write_file_populates_lsp_diagnostics_when_layer_returns_block(tmp_path):
"""When the LSP layer returns a non-empty block, write_file puts it
into the ``lsp_diagnostics`` field NOT into ``lint.output``."""
fops = ShellFileOperations(LocalEnvironment(cwd=str(tmp_path)))
target = tmp_path / "x.py"
block = "<diagnostics file=\"x.py\">\nERROR [1:1] problem\n</diagnostics>"
with patch.object(fops, "_maybe_lsp_diagnostics", return_value=block):
res = fops.write_file(str(target), "x = 1\n")
assert res.lsp_diagnostics == block
# Lint is the syntax check, which is clean for "x = 1" — must NOT
# have the LSP block folded into it.
assert res.lint == {"status": "ok", "output": ""}
def test_write_file_lsp_diagnostics_none_when_layer_returns_empty(tmp_path):
fops = ShellFileOperations(LocalEnvironment(cwd=str(tmp_path)))
target = tmp_path / "x.py"
with patch.object(fops, "_maybe_lsp_diagnostics", return_value=""):
res = fops.write_file(str(target), "x = 1\n")
assert res.lsp_diagnostics is None
def test_write_file_skips_lsp_when_syntax_failed(tmp_path):

View file

@ -52,35 +52,12 @@ def test_disabled_emits_at_debug(caplog_lsp):
# ---------------------------------------------------------------------------
def test_active_for_fires_once_per_root(caplog_lsp):
for _ in range(50):
eventlog.log_active("pyright", "/proj")
info_records = [
r for r in caplog_lsp.records
if r.levelno == logging.INFO and "active for" in r.getMessage()
]
assert len(info_records) == 1
def test_active_for_fires_per_distinct_root(caplog_lsp):
eventlog.log_active("pyright", "/proj-a")
eventlog.log_active("pyright", "/proj-b")
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
assert len(info) == 2
def test_active_for_separate_per_server(caplog_lsp):
eventlog.log_active("pyright", "/proj")
eventlog.log_active("typescript", "/proj")
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
assert len(info) == 2
def test_no_project_root_fires_once_per_path(caplog_lsp):
for _ in range(5):
eventlog.log_no_project_root("pyright", "/orphan.py")
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
assert len(info) == 1
# ---------------------------------------------------------------------------
@ -101,40 +78,14 @@ def test_diagnostics_always_info(caplog_lsp):
# ---------------------------------------------------------------------------
def test_server_unavailable_warns_once_per_binary(caplog_lsp):
for _ in range(20):
eventlog.log_server_unavailable("pyright", "pyright-langserver")
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 1
assert "pyright-langserver" in warns[0].getMessage()
def test_server_unavailable_separate_per_binary(caplog_lsp):
eventlog.log_server_unavailable("pyright", "pyright-langserver")
eventlog.log_server_unavailable("typescript", "typescript-language-server")
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 2
def test_no_server_configured_warns_once(caplog_lsp):
for _ in range(10):
eventlog.log_no_server_configured("pyright")
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 1
def test_timeout_warns_every_call(caplog_lsp):
for _ in range(3):
eventlog.log_timeout("pyright", "/x.py")
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 3
def test_server_error_warns_every_call(caplog_lsp):
for _ in range(3):
eventlog.log_server_error("pyright", "/x.py", RuntimeError("boom"))
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 3
def test_spawn_failed_warns(caplog_lsp):
@ -149,12 +100,6 @@ def test_spawn_failed_warns(caplog_lsp):
# ---------------------------------------------------------------------------
def test_log_lines_use_lsp_prefix(caplog_lsp):
eventlog.log_clean("pyright", "/x.py")
eventlog.log_active("pyright", "/proj")
eventlog.log_diagnostics("typescript", "/y.ts", 2)
for r in caplog_lsp.records:
assert r.getMessage().startswith("lsp[")
# ---------------------------------------------------------------------------
@ -178,12 +123,6 @@ def test_thousand_clean_writes_emit_one_info(caplog_lsp):
# ---------------------------------------------------------------------------
def test_short_path_uses_relative_when_inside_cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
sub = tmp_path / "x.py"
sub.write_text("")
out = eventlog._short_path(str(sub))
assert out == "x.py"
def test_short_path_keeps_absolute_when_outside(tmp_path, monkeypatch):
@ -195,5 +134,3 @@ def test_short_path_keeps_absolute_when_outside(tmp_path, monkeypatch):
assert out == "/var/log/foo.txt" or not out.startswith("..")
def test_short_path_handles_empty_string():
assert eventlog._short_path("") == ""

View file

@ -28,43 +28,8 @@ from agent.lsp.install import INSTALL_RECIPES
# ---------------------------------------------------------------------------
def test_typescript_recipe_includes_typescript_sdk():
recipe = INSTALL_RECIPES["typescript-language-server"]
extras = recipe.get("extra_pkgs") or []
assert "typescript" in extras, (
"typescript-language-server requires the `typescript` SDK as a "
"sibling install — without it `initialize` fails with "
"'Could not find a valid TypeScript installation'."
)
def test_install_npm_passes_extras_to_npm_command(tmp_path, monkeypatch):
"""Verify the npm subprocess is invoked with both pkg AND extras."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
captured = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
# Pretend npm succeeded but binary doesn't exist — install code
# will return None, which is fine for this test.
return MagicMock(returncode=0, stderr="")
from agent.lsp import install as install_mod
monkeypatch.setattr(install_mod.subprocess, "run", fake_run)
monkeypatch.setattr(install_mod.shutil, "which", lambda c: "/usr/bin/npm" if c == "npm" else None)
install_mod._install_npm("typescript-language-server", "typescript-language-server",
extra_pkgs=["typescript"])
cmd = captured["cmd"]
assert "typescript-language-server" in cmd
assert "typescript" in cmd
# Both must come AFTER the npm flags, in install-target position
install_idx = cmd.index("install")
assert cmd.index("typescript-language-server") > install_idx
assert cmd.index("typescript") > install_idx
def test_install_npm_works_without_extras(tmp_path, monkeypatch):
@ -94,21 +59,6 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch):
assert install_targets == ["pyright"]
def test_existing_binary_finds_windows_wrapper_in_staging(tmp_path, monkeypatch):
"""Installed Windows shims should satisfy later status/probe calls."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import install as install_mod
wrapper = install_mod.hermes_lsp_bin_dir() / "pyright-langserver.cmd"
wrapper.write_text("@echo off\n")
wrapper.chmod(0o755)
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
monkeypatch.setattr(install_mod.shutil, "which", lambda _name: None)
assert install_mod._existing_binary("pyright-langserver") == str(wrapper)
assert install_mod.detect_status("pyright") == "installed"
def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch):
@ -140,27 +90,8 @@ def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch):
# ---------------------------------------------------------------------------
def test_backend_warnings_quiet_when_bash_not_installed(tmp_path, monkeypatch):
"""No bash → no warning."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import cli as lsp_cli
with patch("shutil.which", return_value=None):
notes = lsp_cli._backend_warnings()
assert notes == []
def test_backend_warnings_quiet_when_bash_and_shellcheck_both_present(tmp_path, monkeypatch):
"""Both installed → no warning."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import cli as lsp_cli
def which(name):
return f"/usr/bin/{name}" # both found
with patch("shutil.which", side_effect=which):
notes = lsp_cli._backend_warnings()
assert notes == []
def test_backend_warnings_fires_when_bash_installed_but_shellcheck_missing(tmp_path, monkeypatch):
@ -206,81 +137,12 @@ def test_status_output_includes_backend_warnings_section(tmp_path, monkeypatch):
# ---------------------------------------------------------------------------
def test_npx_tsc_missing_treated_as_skipped():
"""The original bug: ``npx tsc`` errors when tsc isn't installed.
Without this fix, the lint result is ``error``, which means the LSP
semantic tier (gated on ``success or skipped``) is skipped the user
gets a useless tooling-error message instead of real diagnostics.
"""
from tools.file_operations import _looks_like_linter_unusable
npx_failure_output = (
" \n"
" This is not the tsc command you are looking for \n"
" \n"
"\n"
"To get access to the TypeScript compiler, tsc, from the command line either:\n"
"- Use npm install typescript to first add TypeScript to your project before using npx\n"
)
assert _looks_like_linter_unusable("npx", npx_failure_output) is True
def test_real_lint_error_not_classified_as_unusable():
"""A genuine TypeScript type error must NOT be misclassified."""
from tools.file_operations import _looks_like_linter_unusable
real_error = (
"bad.ts:5:1 - error TS2322: Type 'number' is not assignable to type 'string'.\n"
"5 const x: string = greet(42);\n"
" ~~~~~~~~~~~~~~~\n"
)
assert _looks_like_linter_unusable("npx", real_error) is False
def test_unknown_base_cmd_returns_false():
"""Unfamiliar linters fall through and use the normal error path."""
from tools.file_operations import _looks_like_linter_unusable
assert _looks_like_linter_unusable("eslint", "any output") is False
assert _looks_like_linter_unusable("", "anything") is False
def test_check_lint_returns_skipped_when_npx_tsc_unusable(tmp_path):
"""Integration: _check_lint sees npx exit non-zero with the npx banner
and returns a ``skipped`` LintResult so LSP can still run."""
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
ts_file = tmp_path / "bad.ts"
ts_file.write_text("const x: string = 42;\n")
env = LocalEnvironment()
fops = ShellFileOperations(env)
# Patch _exec to simulate ``npx tsc`` failing because tsc is missing.
npx_banner = (
" \n"
" This is not the tsc command you are looking for \n"
)
def fake_exec(cmd, **kwargs):
result = MagicMock()
result.exit_code = 1
result.stdout = npx_banner
return result
with patch.object(fops, "_exec", side_effect=fake_exec), \
patch.object(fops, "_has_command", return_value=True):
lint = fops._check_lint(str(ts_file))
assert lint.skipped is True, (
f"expected skipped (so LSP runs); got success={lint.success}, "
f"output={lint.output!r}"
)
assert "not usable" in (lint.message or "")
def test_check_lint_returns_error_for_real_ts_type_errors(tmp_path):

View file

@ -59,16 +59,6 @@ def test_get_service_registers_atexit_handler_once(monkeypatch):
assert registrations[0] is lsp_module._atexit_shutdown
def test_atexit_shutdown_calls_shutdown_service(monkeypatch):
"""The atexit-registered wrapper invokes ``shutdown_service`` and
swallows any exception by the time atexit fires, the user has
already seen the response and a noisy traceback would be clutter."""
called = []
monkeypatch.setattr(
lsp_module, "shutdown_service", lambda: called.append("shutdown")
)
lsp_module._atexit_shutdown()
assert called == ["shutdown"]
def test_atexit_shutdown_swallows_exceptions(monkeypatch):
@ -98,47 +88,9 @@ def test_shutdown_service_idempotent(monkeypatch):
assert fake_svc.shutdown.call_count == 1
def test_shutdown_service_no_op_when_never_started():
"""Calling shutdown without ever creating the service is safe."""
lsp_module.shutdown_service() # must not raise
def test_shutdown_service_swallows_exception(monkeypatch):
"""An exception during ``svc.shutdown()`` must not propagate —
the caller (often atexit) has nothing useful to do with it."""
fake_svc = MagicMock()
fake_svc.is_active.return_value = True
fake_svc.shutdown = MagicMock(side_effect=RuntimeError("kill -9 already"))
monkeypatch.setattr(
lsp_module.LSPService, "create_from_config", classmethod(lambda cls: fake_svc)
)
monkeypatch.setattr(atexit, "register", lambda fn: None)
lsp_module.get_service()
lsp_module.shutdown_service() # must not raise
def test_get_service_returns_none_for_inactive_service(monkeypatch):
"""A service whose ``is_active()`` returns False is treated as
not running callers see ``None`` and fall back."""
fake_svc = MagicMock()
fake_svc.is_active.return_value = False
monkeypatch.setattr(
lsp_module.LSPService, "create_from_config", classmethod(lambda cls: fake_svc)
)
monkeypatch.setattr(atexit, "register", lambda fn: None)
assert lsp_module.get_service() is None
# Subsequent call returns None too — but the inactive instance is
# cached so we don't re-build it on every check.
assert lsp_module.get_service() is None
def test_get_service_returns_none_when_create_fails(monkeypatch):
"""Service factory returning ``None`` (no config, etc.) propagates."""
monkeypatch.setattr(
lsp_module.LSPService, "create_from_config", classmethod(lambda cls: None)
)
monkeypatch.setattr(atexit, "register", lambda fn: None)
assert lsp_module.get_service() is None

View file

@ -25,32 +25,12 @@ def test_powershell_extensions_route_to_pses():
assert s.server_id == "powershell"
def test_powershell_language_ids():
assert language_id_for("a.ps1") == "powershell"
assert language_id_for("a.psm1") == "powershell"
assert language_id_for("a.psd1") == "powershell"
def test_powershell_install_status_is_manual_tier():
# PSES has no npm/go/pip recipe; it's manual-only (like rust-analyzer).
# When pwsh isn't on PATH the status is manual-only, not "missing".
status = detect_status("powershell")
assert status in {"manual-only", "installed"}
def test_spawn_skips_when_pwsh_missing(monkeypatch, tmp_path):
monkeypatch.setattr(srv, "_which", lambda *names: None)
ctx = ServerContext(workspace_root=str(tmp_path), install_strategy="manual")
assert srv._spawn_powershell_es(str(tmp_path), ctx) is None
def test_spawn_skips_when_bundle_missing(monkeypatch, tmp_path):
# pwsh present, but no bundle anywhere.
monkeypatch.setattr(srv, "_which", lambda *names: "/usr/bin/pwsh")
monkeypatch.delenv("PSES_BUNDLE_PATH", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home"))
ctx = ServerContext(workspace_root=str(tmp_path), install_strategy="manual")
assert srv._spawn_powershell_es(str(tmp_path), ctx) is None
def _make_fake_bundle(root) -> str:
@ -79,20 +59,6 @@ def test_spawn_builds_command_with_bundle_via_env(monkeypatch, tmp_path):
assert "-NoProfile" in spec.command
def test_spawn_prefers_command_override_bundle(monkeypatch, tmp_path):
monkeypatch.setattr(srv, "_which", lambda *names: "/usr/bin/pwsh")
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home"))
monkeypatch.delenv("PSES_BUNDLE_PATH", raising=False)
bundle = _make_fake_bundle(tmp_path)
ctx = ServerContext(
workspace_root=str(tmp_path),
install_strategy="manual",
binary_overrides={"powershell": [bundle]},
)
spec = srv._spawn_powershell_es(str(tmp_path), ctx)
assert spec is not None
assert bundle in spec.command[-1]
def test_bundle_path_init_override_not_leaked_into_init_options(monkeypatch, tmp_path):

View file

@ -53,13 +53,6 @@ def test_encode_message_uses_compact_separators_and_utf8():
assert b'"id":1' in body
def test_encode_message_handles_unicode_in_strings():
msg = {"jsonrpc": "2.0", "method": "log", "params": {"text": "🚀 ünıcödé"}}
out = encode_message(msg)
header_end = out.index(b"\r\n\r\n") + 4
declared = int(out[: out.index(b"\r\n")].split(b": ")[1])
assert declared == len(out[header_end:])
assert json.loads(out[header_end:].decode("utf-8")) == msg
# ---------------------------------------------------------------------------
@ -75,44 +68,14 @@ async def _stream_from_bytes(data: bytes) -> asyncio.StreamReader:
return reader
@pytest.mark.asyncio
async def test_read_message_round_trip():
msg = {"jsonrpc": "2.0", "method": "ping"}
reader = await _stream_from_bytes(encode_message(msg))
parsed = await read_message(reader)
assert parsed == msg
@pytest.mark.asyncio
async def test_read_message_clean_eof_returns_none():
reader = await _stream_from_bytes(b"")
assert await read_message(reader) is None
@pytest.mark.asyncio
async def test_read_message_truncated_body_raises():
msg = encode_message({"jsonrpc": "2.0", "method": "x"})
truncated = msg[: -3] # cut the body
reader = await _stream_from_bytes(truncated)
with pytest.raises(LSPProtocolError):
await read_message(reader)
@pytest.mark.asyncio
async def test_read_message_missing_content_length_raises():
bad = b"X-Other: 5\r\n\r\n12345"
reader = await _stream_from_bytes(bad)
with pytest.raises(LSPProtocolError):
await read_message(reader)
@pytest.mark.asyncio
async def test_read_message_two_messages_back_to_back():
a = encode_message({"jsonrpc": "2.0", "method": "a"})
b = encode_message({"jsonrpc": "2.0", "method": "b"})
reader = await _stream_from_bytes(a + b)
assert (await read_message(reader))["method"] == "a"
assert (await read_message(reader))["method"] == "b"
@pytest.mark.asyncio
@ -132,14 +95,8 @@ async def test_read_message_rejects_runaway_header():
# ---------------------------------------------------------------------------
def test_make_request_includes_id_and_method():
msg = make_request(7, "ping", {"v": 1})
assert msg == {"jsonrpc": "2.0", "id": 7, "method": "ping", "params": {"v": 1}}
def test_make_request_omits_params_when_none():
msg = make_request(7, "ping", None)
assert "params" not in msg
def test_make_notification_omits_id():
@ -148,9 +105,6 @@ def test_make_notification_omits_id():
assert msg["method"] == "log"
def test_make_response_carries_result():
msg = make_response(7, {"ok": True})
assert msg["id"] == 7 and msg["result"] == {"ok": True}
def test_make_error_response_shape():
@ -165,19 +119,10 @@ def test_make_error_response_shape():
# ---------------------------------------------------------------------------
def test_classify_message_request():
msg = {"jsonrpc": "2.0", "id": 1, "method": "x"}
assert classify_message(msg) == ("request", 1)
def test_classify_message_response():
msg = {"jsonrpc": "2.0", "id": 1, "result": None}
assert classify_message(msg) == ("response", 1)
def test_classify_message_notification():
msg = {"jsonrpc": "2.0", "method": "log"}
assert classify_message(msg) == ("notification", "log")
def test_classify_message_invalid():

View file

@ -22,68 +22,22 @@ def _diag(line=0, col=0, sev=1, code="E001", source="ls", msg="oops"):
}
def test_format_diagnostic_uses_one_indexed_position():
line = format_diagnostic(_diag(line=4, col=2))
assert "[5:3]" in line # +1 on both
def test_format_diagnostic_includes_severity_label():
assert format_diagnostic(_diag(sev=1)).startswith("ERROR")
assert format_diagnostic(_diag(sev=2)).startswith("WARN")
assert format_diagnostic(_diag(sev=3)).startswith("INFO")
assert format_diagnostic(_diag(sev=4)).startswith("HINT")
def test_format_diagnostic_includes_code_and_source():
line = format_diagnostic(_diag(code="X42", source="src"))
assert "[X42]" in line
assert "(src)" in line
def test_format_diagnostic_omits_missing_optional_fields():
line = format_diagnostic(
{
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 0},
},
"severity": 1,
"message": "bare",
}
)
assert "[" not in line.split("]", 1)[1] # no extra brackets after the position
assert "(" not in line
def test_report_for_file_returns_empty_when_only_warnings():
"""Default severity filter is ERROR-only."""
report = report_for_file("/x.py", [_diag(sev=2)])
assert report == ""
def test_report_for_file_emits_block_with_errors():
diag = _diag(msg="real error")
report = report_for_file("/x.py", [diag])
assert "<diagnostics file=\"/x.py\">" in report
assert "real error" in report
assert "</diagnostics>" in report
def test_report_for_file_caps_at_max_per_file():
diags = [_diag(line=i) for i in range(MAX_PER_FILE + 5)]
report = report_for_file("/x.py", diags)
assert "and 5 more" in report
def test_report_for_file_respects_custom_severities():
diag = _diag(sev=2, msg="warn")
report = report_for_file("/x.py", [diag], severities=frozenset({1, 2}))
assert "warn" in report
def test_truncate_below_limit_unchanged():
s = "abc" * 100
assert truncate(s, limit=4000) == s
def test_truncate_above_limit_appends_marker():
@ -112,14 +66,6 @@ def test_format_diagnostic_escapes_html_in_message():
assert "&lt;tool_call&gt;" in line
def test_format_diagnostic_collapses_newlines_in_message():
"""Raw newlines in a message must not produce extra lines in the output."""
diag = _diag(msg="line one\nline two\rline three")
line = format_diagnostic(diag)
# Single-line output: no embedded newlines from the message field.
assert "\n" not in line
assert "\r" not in line
assert "line one line two line three" in line
def test_format_diagnostic_caps_message_length():
@ -143,15 +89,6 @@ def test_format_diagnostic_escapes_brackets_in_code_and_source():
assert "&lt;/diagnostics&gt;" in line
def test_format_diagnostic_drops_control_characters():
"""Non-printable control bytes must be stripped from the output."""
# NUL, BEL, and a stray ESC — none belong in a single-line summary.
diag = _diag(msg="visible\x00\x07\x1bend")
line = format_diagnostic(diag)
assert "\x00" not in line
assert "\x07" not in line
assert "\x1b" not in line
assert "visibleend" in line
def test_report_for_file_escapes_file_path_attribute():

View file

@ -77,33 +77,8 @@ def mock_pyright(monkeypatch, tmp_path):
pass
def test_service_returns_empty_when_disabled(tmp_path):
svc = LSPService(
enabled=False,
wait_mode="document",
wait_timeout=2.0,
install_strategy="auto",
)
assert not svc.is_active()
f = tmp_path / "x.py"
f.write_text("")
assert svc.get_diagnostics_sync(str(f)) == []
svc.shutdown()
def test_service_skips_files_outside_workspace(tmp_path):
"""Files outside any git worktree must not trigger LSP."""
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
f = tmp_path / "x.py"
f.write_text("")
# No .git anywhere — service should report not enabled for this file.
assert not svc.enabled_for(str(f))
svc.shutdown()
def test_service_e2e_delta_filter(mock_pyright):
@ -158,53 +133,8 @@ def test_service_e2e_delta_filter_with_line_shift(mock_pyright):
svc.shutdown()
def test_service_status_includes_clients(mock_pyright):
repo = mock_pyright
f = repo / "x.py"
f.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=3.0,
install_strategy="manual",
)
try:
svc.get_diagnostics_sync(str(f))
info = svc.get_status()
assert info["enabled"] is True
assert any(c["server_id"] == "pyright" for c in info["clients"])
finally:
svc.shutdown()
def test_service_reaps_client_after_idle_timeout(mock_pyright):
repo = mock_pyright
f = repo / "x.py"
f.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=3.0,
install_strategy="manual",
idle_timeout=0.2,
)
try:
svc.get_diagnostics_sync(str(f))
assert svc.get_status()["clients"]
client = next(iter(svc._clients.values()))
process = client._proc
assert process is not None
deadline = time.monotonic() + 2.0
while svc.get_status()["clients"] and time.monotonic() < deadline:
time.sleep(0.02)
while process.returncode is None and time.monotonic() < deadline:
time.sleep(0.02)
assert svc.get_status()["clients"] == []
assert process.returncode is not None
finally:
svc.shutdown()
def test_reused_client_refreshes_last_used_and_survives_reap(mock_pyright):
@ -289,72 +219,9 @@ def test_reaper_survives_sweep_error(mock_pyright):
svc.shutdown()
def test_create_from_config_reads_idle_timeout(monkeypatch):
"""``lsp.idle_timeout`` in config.yaml reaches the service."""
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"lsp": {"enabled": False, "idle_timeout": 42}},
)
monkeypatch.setattr(
"hermes_cli.config.load_config_readonly",
lambda: {"lsp": {"enabled": False, "idle_timeout": 42}},
)
svc = LSPService.create_from_config()
assert svc is not None
assert svc._idle_timeout == 42.0
def test_create_from_config_invalid_idle_timeout_falls_back(monkeypatch):
from agent.lsp.manager import DEFAULT_IDLE_TIMEOUT
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"lsp": {"enabled": False, "idle_timeout": "not-a-number"}},
)
monkeypatch.setattr(
"hermes_cli.config.load_config_readonly",
lambda: {"lsp": {"enabled": False, "idle_timeout": "not-a-number"}},
)
svc = LSPService.create_from_config()
assert svc is not None
assert svc._idle_timeout == DEFAULT_IDLE_TIMEOUT
def test_create_from_config_clamps_tiny_idle_timeout(monkeypatch):
"""Sub-floor timeouts are clamped (mid-flight reap could otherwise
escalate an outer timeout into a permanent broken-set entry); 0 still
means disabled and is not clamped."""
from agent.lsp.manager import MIN_IDLE_TIMEOUT
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"lsp": {"enabled": False, "idle_timeout": 2}},
)
monkeypatch.setattr(
"hermes_cli.config.load_config_readonly",
lambda: {"lsp": {"enabled": False, "idle_timeout": 2}},
)
svc = LSPService.create_from_config()
assert svc is not None
assert svc._idle_timeout == MIN_IDLE_TIMEOUT
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"lsp": {"enabled": False, "idle_timeout": 0}},
)
monkeypatch.setattr(
"hermes_cli.config.load_config_readonly",
lambda: {"lsp": {"enabled": False, "idle_timeout": 0}},
)
svc = LSPService.create_from_config()
assert svc is not None
assert svc._idle_timeout == 0
def test_default_config_declares_idle_timeout():
"""The canonical default in DEFAULT_CONFIG matches the manager constant
so config discovery surfaces the knob with the real default value."""
from agent.lsp.manager import DEFAULT_IDLE_TIMEOUT
from hermes_cli.config import DEFAULT_CONFIG
assert float(DEFAULT_CONFIG["lsp"]["idle_timeout"]) == float(DEFAULT_IDLE_TIMEOUT)

View file

@ -69,85 +69,12 @@ def test_shell_linter_skipped_when_lsp_will_handle(ext, tmp_path):
assert "LSP" in (result.message or "")
@pytest.mark.parametrize("ext", [".ts", ".go", ".rs"])
def test_shell_linter_runs_when_lsp_inactive(ext, tmp_path):
"""When LSP is inactive (default config, no service, remote backend, ...),
the shell linter runs as before no behavior change."""
fops = _make_fops()
src = tmp_path / f"clean{ext}"
src.write_text("// content\n")
fake_result = MagicMock()
fake_result.exit_code = 0
fake_result.stdout = ""
with patch.object(fops, "_lsp_will_handle", return_value=False), \
patch.object(fops, "_exec", return_value=fake_result) as exec_mock, \
patch.object(fops, "_has_command", return_value=True):
result = fops._check_lint(str(src))
# _exec must have been called — proving the shell linter ran.
assert exec_mock.called, "shell linter did NOT run when LSP was inactive"
assert result.success is True
@pytest.mark.parametrize("ext", [".py", ".js"])
def test_lsp_does_not_skip_non_redundant_extensions(ext, tmp_path):
"""``py_compile`` and ``node --check`` keep running even when an LSP
server (pyright/pylsp/typescript-language-server-for-JS) is active
they're fast, file-local, and correct, so there's no upside to
suppressing them.
"""
fops = _make_fops()
src = tmp_path / f"clean{ext}"
src.write_text("# valid\n" if ext == ".py" else "// valid\n")
fake_result = MagicMock()
fake_result.exit_code = 0
fake_result.stdout = ""
# Even with LSP claiming the file, the shell linter must still run
# for these extensions.
with patch.object(fops, "_lsp_will_handle", return_value=True), \
patch.object(fops, "_exec", return_value=fake_result) as exec_mock, \
patch.object(fops, "_has_command", return_value=True):
fops._check_lint(str(src))
assert exec_mock.called, (
f"shell linter for {ext} did not run despite being in the "
"'always-run' set (py_compile / node --check)"
)
def test_lsp_will_handle_returns_false_when_service_is_none(tmp_path):
"""``_lsp_will_handle`` must return False when the LSP service hasn't
been initialized otherwise we'd accidentally skip the shell linter
on systems where LSP isn't configured at all."""
fops = _make_fops()
src = tmp_path / "foo.ts"
src.write_text("const x = 1\n")
with patch.object(fops, "_lsp_local_only", return_value=True), \
patch("agent.lsp.get_service", return_value=None):
assert fops._lsp_will_handle(str(src)) is False
def test_lsp_will_handle_returns_false_on_remote_backend(tmp_path):
"""LSP servers run on the host process — remote backends (Docker,
SSH, Modal, ) keep files inside the sandbox where the host LSP
can't reach them. ``_lsp_will_handle`` must short-circuit before
calling into the service in that case."""
fops = _make_fops()
src = tmp_path / "foo.ts"
src.write_text("const x = 1\n")
with patch.object(fops, "_lsp_local_only", return_value=False), \
patch("agent.lsp.get_service") as get_service_mock:
result = fops._lsp_will_handle(str(src))
assert result is False
# Importantly: we never even consulted the service.
assert not get_service_mock.called
def test_lsp_will_handle_swallows_enabled_for_exception(tmp_path):
@ -166,25 +93,6 @@ def test_lsp_will_handle_swallows_enabled_for_exception(tmp_path):
assert fops._lsp_will_handle(str(src)) is False
def test_tsx_stays_out_of_linters_table_for_default_compatibility():
"""Regression: keep ``.tsx`` out of ``LINTERS`` so users with LSP
DISABLED don't suddenly get the broken ``npx tsc --noEmit FILE.tsx``
invocation that ``.ts`` historically used to get.
Pre-PR behavior: ``.tsx`` had no entry in ``LINTERS``, so it fell
through to ``ext not in LINTERS`` ``LintResult(skipped=True,
message="No linter for .tsx files")``. This PR preserves that for
the default config.
When LSP IS enabled, ``.tsx`` is still covered by the LSP tier via
``_maybe_lsp_diagnostics`` (typescript-language-server claims
``.tsx`` in its extensions list) the diagnostics show up in the
``lsp_diagnostics`` field, not the ``lint`` field.
"""
from tools.file_operations import LINTERS, _SHELL_LINTER_LSP_REDUNDANT
assert ".tsx" not in LINTERS
assert ".tsx" not in _SHELL_LINTER_LSP_REDUNDANT
def test_tsx_default_check_lint_returns_skipped(tmp_path):

View file

@ -46,52 +46,8 @@ def _client(workspace: Path, script: str, **env_extra: str) -> LSPClient:
)
@pytest.mark.asyncio
async def test_stale_push_does_not_satisfy_wait(tmp_path: Path):
"""A push from the previous edit cycle must not end the wait early.
The 'stale' mock publishes an error for the original content and
then goes silent the wait after the edit must time out (False),
not return instantly on the leftover push.
"""
f = tmp_path / "x.py"
f.write_text("bad code\n")
client = _client(tmp_path, "stale")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
assert await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
assert len(client.diagnostics_for(str(f))) == 1 # pre-edit error is real
# Fix the file. The stale server never re-checks.
f.write_text("good code\n")
v1 = await client.open_file(str(f), language_id="python")
fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=1.0)
assert fresh is False, "wait must not be satisfied by pre-edit leftovers"
finally:
await client.shutdown()
@pytest.mark.asyncio
async def test_fresh_only_excludes_stale_stores(tmp_path: Path):
f = tmp_path / "x.py"
f.write_text("bad code\n")
client = _client(tmp_path, "stale")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
f.write_text("good code\n")
await client.open_file(str(f), language_id="python")
# Merged legacy view still exposes the leftover push...
assert len(client.diagnostics_for(str(f))) == 1
# ...but the fresh-only view correctly reports no verdict yet.
assert client.diagnostics_for(str(f), fresh_only=True) == []
finally:
await client.shutdown()
@pytest.mark.asyncio
@ -117,56 +73,8 @@ async def test_slow_push_is_waited_for(tmp_path: Path):
await client.shutdown()
@pytest.mark.asyncio
async def test_wait_timeout_param_overrides_mode_budget(tmp_path: Path):
"""The explicit timeout must control the wait budget (config plumb)."""
import asyncio
f = tmp_path / "x.py"
f.write_text("bad code\n")
client = _client(tmp_path, "stale")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
f.write_text("good code\n")
v1 = await client.open_file(str(f), language_id="python")
loop = asyncio.get_event_loop()
start = loop.time()
fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=0.5)
elapsed = loop.time() - start
assert fresh is False
# Must respect ~0.5s, not the 5s document default.
assert elapsed < 3.0
finally:
await client.shutdown()
@pytest.mark.asyncio
async def test_stale_pull_result_dropped_when_change_races(tmp_path: Path):
"""A pull answered for pre-edit content must not read as fresh after
a didChange raced past it (version-tag anchoring)."""
f = tmp_path / "x.py"
f.write_text("bad code\n")
client = _client(tmp_path, "clean")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
doc = client._docs[os.path.abspath(str(f))]
assert doc.fresh_pull()
# Simulate an edit racing in: the version bump invalidates the
# stored pull without any explicit clearing.
f.write_text("good code\n")
await client.open_file(str(f), language_id="python")
assert not doc.fresh_pull()
assert client.diagnostics_for(str(f), fresh_only=True) == []
finally:
await client.shutdown()
# ---------------------------------------------------------------------------

View file

@ -23,10 +23,6 @@ def _clear():
clear_cache()
def test_find_git_worktree_returns_none_outside_repo(tmp_path: Path):
sub = tmp_path / "sub"
sub.mkdir()
assert find_git_worktree(str(sub)) is None
def test_find_git_worktree_finds_dotgit(tmp_path: Path):
@ -38,31 +34,10 @@ def test_find_git_worktree_finds_dotgit(tmp_path: Path):
assert find_git_worktree(str(sub)) == str(repo)
def test_find_git_worktree_handles_dotgit_file(tmp_path: Path):
"""``.git`` can also be a file (gitfile pointing into a worktree)."""
repo = tmp_path / "repo"
repo.mkdir()
(repo / ".git").write_text("gitdir: /elsewhere\n")
assert find_git_worktree(str(repo)) == str(repo)
def test_is_inside_workspace_true_for_subpath(tmp_path: Path):
root = tmp_path / "p"
root.mkdir()
sub = root / "x" / "y.py"
sub.parent.mkdir(parents=True)
sub.write_text("")
assert is_inside_workspace(str(sub), str(root))
def test_is_inside_workspace_false_for_unrelated(tmp_path: Path):
a = tmp_path / "a"
b = tmp_path / "b"
a.mkdir()
b.mkdir()
f = b / "x.py"
f.write_text("")
assert not is_inside_workspace(str(f), str(a))
def test_nearest_root_finds_first_marker(tmp_path: Path):
@ -74,25 +49,8 @@ def test_nearest_root_finds_first_marker(tmp_path: Path):
assert found == str(root)
def test_nearest_root_excludes_take_priority(tmp_path: Path):
"""If an exclude marker matches first, return None."""
root = tmp_path / "p"
sub = root / "deno-app"
sub.mkdir(parents=True)
(sub / "deno.json").write_text("{}")
(root / "package.json").write_text("{}") # would match if not for exclude
found = nearest_root(
str(sub / "main.ts"),
["package.json"],
excludes=["deno.json"],
)
assert found is None
def test_nearest_root_returns_none_when_no_marker(tmp_path: Path):
f = tmp_path / "x.py"
f.write_text("")
assert nearest_root(str(f), ["pyproject.toml"]) is None
def test_resolve_workspace_for_file_uses_cwd_first(tmp_path: Path, monkeypatch):
@ -107,30 +65,8 @@ def test_resolve_workspace_for_file_uses_cwd_first(tmp_path: Path, monkeypatch):
assert gated is True
def test_resolve_workspace_for_file_no_repo_returns_none(tmp_path: Path, monkeypatch):
monkeypatch.chdir(str(tmp_path))
f = tmp_path / "x.py"
f.write_text("")
root, gated = resolve_workspace_for_file(str(f))
assert root is None
assert gated is False
def test_resolve_workspace_falls_back_to_file_location(tmp_path: Path, monkeypatch):
"""When cwd isn't a git repo but the file is inside one, we still
discover the workspace from the file's path."""
not_a_repo = tmp_path / "loose"
not_a_repo.mkdir()
monkeypatch.chdir(str(not_a_repo))
repo = tmp_path / "actual-repo"
(repo / ".git").mkdir(parents=True)
f = repo / "x.py"
f.write_text("")
root, gated = resolve_workspace_for_file(str(f))
assert root == str(repo)
assert gated is True
def test_normalize_path_expands_tilde(monkeypatch):

View file

@ -118,38 +118,6 @@ def test_codex_usage_falls_back_to_native_credential_pool(monkeypatch, codex_usa
assert "ChatGPT-Account-Id" not in calls[0]["headers"]
def test_codex_usage_does_not_swap_to_pool_on_transient_resolver_error(monkeypatch, codex_usage_payload):
"""A transient refresh/network failure (non-AuthError) must NOT silently
downgrade to a possibly-different pool account. It fails open (no snapshot)
instead of reporting the wrong account's usage."""
calls = []
monkeypatch.setattr(
account_usage.httpx,
"Client",
lambda timeout: _FakeClient(calls, codex_usage_payload),
)
monkeypatch.setattr(
account_usage,
"resolve_codex_runtime_credentials",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("refresh endpoint 503")),
)
pool_entry = SimpleNamespace(
runtime_api_key="pooled-token-WRONG-ACCOUNT",
runtime_base_url="https://chatgpt.com/backend-api/codex",
)
pool = SimpleNamespace(select=lambda: pool_entry)
import agent.credential_pool as credential_pool
# If the guard regressed, this pool would be consulted and return a snapshot
# for the wrong account. It must NOT be.
monkeypatch.setattr(credential_pool, "load_pool", lambda provider: pool)
snapshot = account_usage.fetch_account_usage("openai-codex")
assert snapshot is None
assert calls == [] # HTTP usage endpoint never hit with a wrong-account token
def test_codex_usage_account_id_read_failure_keeps_singleton_token(monkeypatch, codex_usage_payload):
@ -194,47 +162,6 @@ def test_codex_usage_account_id_read_failure_keeps_singleton_token(monkeypatch,
assert "ChatGPT-Account-Id" not in calls[0]["headers"]
def test_codex_usage_treats_wham_used_percent_as_used_not_remaining(monkeypatch):
"""ChatGPT UI says "left"; /wham/usage.used_percent is already used."""
payload = {
"plan_type": "plus",
"rate_limit": {
"primary_window": {
"used_percent": 85,
"reset_at": 1779846359,
},
"secondary_window": {
"used_percent": 14,
"reset_at": 1780230796,
},
},
"credits": {"has_credits": False},
}
calls = []
monkeypatch.setattr(
account_usage.httpx,
"Client",
lambda timeout: _FakeClient(calls, payload),
)
monkeypatch.setattr(
account_usage,
"resolve_codex_runtime_credentials",
lambda **kwargs: (_ for _ in ()).throw(AssertionError("explicit auth should be used")),
)
snapshot = account_usage.fetch_account_usage(
"openai-codex",
base_url="https://chatgpt.com/backend-api/codex",
api_key="live-agent-token",
)
assert snapshot is not None
assert [window.used_percent for window in snapshot.windows] == [85, 14]
rendered = "\n".join(account_usage.render_account_usage_lines(snapshot, markdown=True))
assert "85% used" in rendered
assert "14% used" in rendered
assert "15% used" not in rendered
assert "86% used" not in rendered
# ── Banked rate-limit reset credits (`/usage reset`) ─────────────────────────
@ -275,154 +202,18 @@ def _usage_payload_with_resets(primary_used, secondary_used, banked):
}
def test_usage_snapshot_shows_banked_resets_hint(monkeypatch):
calls = []
monkeypatch.setattr(
account_usage.httpx,
"Client",
lambda timeout: _FakeResetClient(calls, _usage_payload_with_resets(21, 4, 2)),
)
snapshot = account_usage.fetch_account_usage(
"openai-codex",
base_url="https://chatgpt.com/backend-api/codex",
api_key="live-agent-token",
)
assert snapshot is not None
rendered = "\n".join(account_usage.render_account_usage_lines(snapshot))
assert "You have 2 resets banked - use /usage reset to activate" in rendered
def test_usage_snapshot_hides_reset_hint_when_none_banked(monkeypatch, codex_usage_payload):
calls = []
monkeypatch.setattr(
account_usage.httpx,
"Client",
lambda timeout: _FakeResetClient(calls, codex_usage_payload),
)
snapshot = account_usage.fetch_account_usage(
"openai-codex",
base_url="https://chatgpt.com/backend-api/codex",
api_key="live-agent-token",
)
assert snapshot is not None
rendered = "\n".join(account_usage.render_account_usage_lines(snapshot))
assert "banked" not in rendered
def test_redeem_blocked_when_limits_not_exhausted(monkeypatch):
calls = []
monkeypatch.setattr(
account_usage.httpx,
"Client",
lambda timeout: _FakeResetClient(calls, _usage_payload_with_resets(60, 30, 2)),
)
result = account_usage.redeem_codex_reset_credit(
base_url="https://chatgpt.com/backend-api/codex",
api_key="live-agent-token",
)
assert result.status == "not_exhausted"
assert not result.redeemed
assert "--force" in result.message
assert "60% used" in result.message
assert result.available_count == 2
# The consume endpoint must never be hit — the credit is protected.
assert [c["method"] for c in calls] == ["GET"]
def test_redeem_force_bypasses_exhaustion_guard(monkeypatch):
calls = []
monkeypatch.setattr(
account_usage.httpx,
"Client",
lambda timeout: _FakeResetClient(
calls,
_usage_payload_with_resets(60, 30, 2),
consume_payload={"code": "reset", "windows_reset": 2},
),
)
result = account_usage.redeem_codex_reset_credit(
base_url="https://chatgpt.com/backend-api/codex",
api_key="live-agent-token",
force=True,
)
assert result.redeemed
assert result.windows_reset == 2
assert result.available_count == 1 # 2 banked - 1 spent
assert "1 banked reset remaining" in result.message
post = [c for c in calls if c["method"] == "POST"][0]
assert post["url"] == "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"
assert post["json"]["redeem_request_id"] # idempotency key present
assert "credit_id" not in post["json"]
def test_redeem_allowed_without_force_when_window_exhausted(monkeypatch):
calls = []
monkeypatch.setattr(
account_usage.httpx,
"Client",
lambda timeout: _FakeResetClient(
calls,
_usage_payload_with_resets(100, 42, 1),
consume_payload={"code": "reset", "windows_reset": 2},
),
)
result = account_usage.redeem_codex_reset_credit(
base_url="https://chatgpt.com/backend-api/codex",
api_key="live-agent-token",
)
assert result.redeemed
assert result.available_count == 0
assert "0 banked resets remaining" in result.message
def test_redeem_refuses_when_no_credits_banked(monkeypatch):
calls = []
monkeypatch.setattr(
account_usage.httpx,
"Client",
lambda timeout: _FakeResetClient(calls, _usage_payload_with_resets(100, 100, 0)),
)
result = account_usage.redeem_codex_reset_credit(
base_url="https://chatgpt.com/backend-api/codex",
api_key="live-agent-token",
)
assert result.status == "no_credits_banked"
assert [c["method"] for c in calls] == ["GET"]
def test_redeem_nothing_to_reset_reports_credit_not_spent(monkeypatch):
calls = []
monkeypatch.setattr(
account_usage.httpx,
"Client",
lambda timeout: _FakeResetClient(
calls,
_usage_payload_with_resets(100, 100, 3),
consume_payload={"code": "nothing_to_reset"},
),
)
result = account_usage.redeem_codex_reset_credit(
base_url="https://chatgpt.com/backend-api/codex",
api_key="live-agent-token",
)
assert result.status == "nothing_to_reset"
assert not result.redeemed
assert "NOT spent" in result.message
assert result.available_count == 3
def test_redeem_missing_credentials_reports_unavailable(monkeypatch):

File diff suppressed because it is too large Load diff

View file

@ -14,14 +14,7 @@ from agent.anthropic_adapter import (
class TestReadClaudeCodeCredentialsFromKeychain:
"""Bug 4: macOS Keychain support for Claude Code >=2.1.114."""
def test_returns_none_on_linux(self):
"""Keychain reading is Darwin-only; must return None on other platforms."""
with patch("agent.anthropic_adapter.platform.system", return_value="Linux"):
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_on_windows(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Windows"):
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_when_security_command_not_found(self):
"""OSError from missing security binary must be handled gracefully."""
@ -37,58 +30,10 @@ class TestReadClaudeCodeCredentialsFromKeychain:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_for_empty_stdout(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_for_non_json_payload(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="not valid json", stderr="")
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_when_password_field_is_missing_claude_ai_oauth(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({"someOtherService": {"accessToken": "tok"}}),
stderr="",
)
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_when_access_token_is_empty(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}}),
stderr="",
)
assert _read_claude_code_credentials_from_keychain() is None
def test_parses_valid_keychain_entry(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({
"claudeAiOauth": {
"accessToken": "kc-access-token-abc",
"refreshToken": "kc-refresh-token-xyz",
"expiresAt": 9999999999999,
}
}),
stderr="",
)
creds = _read_claude_code_credentials_from_keychain()
assert creds is not None
assert creds["accessToken"] == "kc-access-token-abc"
assert creds["refreshToken"] == "kc-refresh-token-xyz"
assert creds["expiresAt"] == 9999999999999
assert creds["source"] == "macos_keychain"
class TestReadClaudeCodeCredentialsPriority:
@ -222,34 +167,7 @@ class TestReadClaudeCodeCredentialsDesync:
assert creds["accessToken"] == "fresh-file-token"
assert creds["source"] == "claude_code_credentials_file"
def test_keychain_fresh_file_expired_returns_keychain(self, tmp_path, monkeypatch):
"""Mirror case: file is the stale source; Keychain wins on validity."""
self._setup(tmp_path, monkeypatch, file_expires_at=self._EXPIRED, file_token="stale-file-token")
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = self._keychain_payload(
access_token="fresh-keychain-token", expires_at=self._FRESH,
)
creds = read_claude_code_credentials()
assert creds is not None
assert creds["accessToken"] == "fresh-keychain-token"
assert creds["source"] == "macos_keychain"
def test_both_valid_prefers_later_expiry_when_file_is_fresher(self, tmp_path, monkeypatch):
"""When both are valid, the one with the later ``expiresAt`` wins so
that any subsequent refresh uses the freshest ``refresh_token``.
"""
self._setup(tmp_path, monkeypatch, file_expires_at=self._FRESH, file_token="newer-file-token")
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = self._keychain_payload(
access_token="older-keychain-token", expires_at=self._FRESH - 1_000_000,
)
creds = read_claude_code_credentials()
assert creds is not None
assert creds["accessToken"] == "newer-file-token"
def test_both_expired_prefers_later_expiry(self, tmp_path, monkeypatch):
"""When both are expired, return the one with the later ``expiresAt``;

View file

@ -39,13 +39,8 @@ def _thinking_on_replay(base_url, signature=SIG, model="k3"):
return [b for b in assistant["content"] if isinstance(b, dict) and b.get("type") == "thinking"]
def test_kimi_coding_keeps_signed_thinking():
thinking = _thinking_on_replay(KIMI)
assert thinking and thinking[0].get("signature") == SIG
def test_kimi_coding_keeps_unsigned_thinking():
assert _thinking_on_replay(KIMI, signature="")
def test_moonshot_keeps_signed_thinking():
@ -53,13 +48,6 @@ def test_moonshot_keeps_signed_thinking():
assert thinking and thinking[0].get("signature") == SIG
def test_deepseek_still_strips_signed_thinking():
# A DeepSeek model on the DeepSeek Anthropic endpoint must strip signed
# thinking on replay. (The model must be a real DeepSeek slug: the bare
# ``k3`` slug is now classified as Kimi family, and a Kimi-family MODEL
# name deliberately preserves thinking regardless of gateway hostname —
# the proxied-endpoint path, see _is_kimi_family_endpoint.)
assert not _thinking_on_replay(DEEPSEEK, model="deepseek-reasoner")
def test_kimi_model_name_on_foreign_gateway_keeps_thinking():
@ -71,8 +59,6 @@ def test_kimi_model_name_on_foreign_gateway_keeps_thinking():
assert _thinking_on_replay(DEEPSEEK, model=model), model
def test_direct_anthropic_keeps_signed_on_latest():
assert _thinking_on_replay(None)
def test_orphan_tool_turn_demotes_and_leaks_no_internal_marker():

View file

@ -52,18 +52,6 @@ def test_strips_all_responses_only_keys():
assert _fake_anthropic_call(**payload) == "OK"
def test_clean_anthropic_payload_is_untouched():
payload = {
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 1024,
"system": "sys",
"tools": [{"name": "x"}],
}
snapshot = dict(payload)
sanitize_anthropic_kwargs(payload)
assert payload == snapshot
assert _fake_anthropic_call(**payload) == "OK"
def test_warns_when_keys_are_stripped(caplog):
@ -77,18 +65,7 @@ def test_warns_when_keys_are_stripped(caplog):
), caplog.records
def test_no_warning_on_clean_payload(caplog):
with caplog.at_level(logging.WARNING, logger="agent.anthropic_adapter"):
sanitize_anthropic_kwargs({"model": "m", "messages": []})
assert not caplog.records
def test_non_dict_input_is_noop():
assert sanitize_anthropic_kwargs(None) is None
assert sanitize_anthropic_kwargs("not a dict") == "not a dict"
def test_responses_only_kwargs_membership():
# Contract: instructions (the reported symptom) plus the sibling
# Responses-shape keys are all covered.
assert {"instructions", "input", "store", "parallel_tool_calls"} <= _RESPONSES_ONLY_KWARGS

View file

@ -79,24 +79,6 @@ class TestAnthropicMcpPrefixStrip:
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "read_file"
def test_restores_single_underscore_mcp_server_tool(self):
"""``mcp__linear_get_issue`` -> ``mcp_linear_get_issue`` (MCP server tool).
MCP server tools are registered under their full single-underscore
``mcp_<server>_<tool>`` name, but they MUST go on the OAuth wire as
double-underscore to dodge the classifier. The response side restores
the single-underscore registry name so dispatch still resolves.
"""
transport = self._get_transport()
block = _make_tool_use_block("mcp__linear_get_issue")
response = _make_response(block)
registry = _FakeRegistry({"mcp_linear_get_issue", "read_file"})
with patch("tools.registry.registry", registry):
result = transport.normalize_response(response, strip_tool_prefix=True)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "mcp_linear_get_issue"
def test_no_strip_when_flag_false(self):
"""When strip_tool_prefix=False, names are never modified."""
@ -111,65 +93,9 @@ class TestAnthropicMcpPrefixStrip:
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "mcp__read_file"
def test_no_strip_when_not_mcp_prefixed(self):
"""Non-``mcp__`` names are untouched regardless of strip flag."""
transport = self._get_transport()
block = _make_tool_use_block("web_search")
response = _make_response(block)
registry = _FakeRegistry({"web_search"})
with patch("tools.registry.registry", registry):
result = transport.normalize_response(response, strip_tool_prefix=True)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "web_search"
def test_preserves_name_when_no_original_in_registry(self):
"""Neither the single-underscore nor bare original is registered.
Safety fallback: keep the full ``mcp__`` name the LLM was told about.
"""
transport = self._get_transport()
block = _make_tool_use_block("mcp__unknown_tool")
response = _make_response(block)
registry = _FakeRegistry({"read_file"}) # no matching original
with patch("tools.registry.registry", registry):
result = transport.normalize_response(response, strip_tool_prefix=True)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "mcp__unknown_tool"
def test_mixed_native_and_mcp_server_tools_same_response(self):
"""A bare native tool and an MCP server tool, both wired as ``mcp__``."""
transport = self._get_transport()
block1 = _make_tool_use_block("mcp__read_file", block_id="tc_1")
block2 = _make_tool_use_block("mcp__linear_get_issue", block_id="tc_2")
response = _make_response(block1, block2)
registry = _FakeRegistry({"read_file", "mcp_linear_get_issue"})
with patch("tools.registry.registry", registry):
result = transport.normalize_response(response, strip_tool_prefix=True)
assert len(result.tool_calls) == 2
assert result.tool_calls[0].name == "read_file"
assert result.tool_calls[1].name == "mcp_linear_get_issue"
def test_prefers_full_wire_name_when_it_resolves_directly(self):
"""If the ``mcp__`` wire name itself is registered, keep it as-is.
Defensive: never rewrite a name that already resolves natively.
"""
transport = self._get_transport()
block = _make_tool_use_block("mcp__foo")
response = _make_response(block)
registry = _FakeRegistry({"foo", "mcp__foo"})
with patch("tools.registry.registry", registry):
result = transport.normalize_response(response, strip_tool_prefix=True)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "mcp__foo"
# ---------------------------------------------------------------------------
@ -191,13 +117,6 @@ class TestAnthropicOAuthOutgoingPrefix:
is_oauth=is_oauth,
)
def test_oauth_adds_double_prefix_to_bare_tool_name(self):
"""OAuth + bare name -> ``mcp__`` prefix added."""
kwargs = self._build([{
"type": "function",
"function": {"name": "read_file", "description": "x", "parameters": {}},
}])
assert [t["name"] for t in kwargs["tools"]] == ["mcp__read_file"]
def test_oauth_promotes_single_underscore_mcp_server_tool(self):
"""OAuth + ``mcp_<server>_<tool>`` -> promoted to double underscore.
@ -219,13 +138,6 @@ class TestAnthropicOAuthOutgoingPrefix:
# never double-prefixed
assert not any(n.startswith("mcp__mcp_") for n in names)
def test_oauth_already_double_prefixed_left_alone(self):
"""OAuth + already-``mcp__`` name -> unchanged (no triple underscore)."""
kwargs = self._build([{
"type": "function",
"function": {"name": "mcp__already", "description": "x", "parameters": {}},
}])
assert [t["name"] for t in kwargs["tools"]] == ["mcp__already"]
def test_oauth_no_single_underscore_mcp_on_wire(self):
"""Mixed set: every wire name is bare-free of single-underscore mcp_."""
@ -243,13 +155,3 @@ class TestAnthropicOAuthOutgoingPrefix:
for n in names:
assert not (n.startswith("mcp_") and not n.startswith("mcp__"))
def test_non_oauth_path_untouched(self):
"""Non-OAuth requests never get the prefix — schemas pass through as-is."""
kwargs = self._build([
{"type": "function", "function": {"name": "read_file",
"description": "x", "parameters": {}}},
{"type": "function", "function": {"name": "mcp_linear_get_issue",
"description": "y", "parameters": {}}},
], is_oauth=False)
names = sorted(t["name"] for t in kwargs["tools"])
assert names == ["mcp_linear_get_issue", "read_file"]

View file

@ -187,70 +187,6 @@ def test_login_token_exchange_uses_platform_claude_host(monkeypatch, tmp_path):
)
def test_login_token_exchange_falls_back_to_console_host(monkeypatch, tmp_path):
"""If ``platform.claude.com`` is unreachable, the login path must fall back
to the legacy ``console.anthropic.com`` host mirroring the refresh path's
fallback list rather than failing outright.
"""
import urllib.request
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
captured_url: Dict[str, str] = {}
_patch_oauth_flow(
monkeypatch,
callback_code="placeholder",
capture_auth_url=captured_url,
)
attempts: list[str] = []
class _FakeResponse:
def __init__(self, body: bytes) -> None:
self._body = body
def __enter__(self):
return self
def __exit__(self, *_exc):
return False
def read(self):
return self._body
def fake_urlopen(req, *_a, **_kw):
attempts.append(req.full_url)
if req.full_url.startswith("https://platform.claude.com"):
raise RuntimeError("HTTP Error 404: Not Found")
body = json.dumps(
{
"access_token": "sk-ant-test-access",
"refresh_token": "sk-ant-test-refresh",
"expires_in": 3600,
}
).encode()
return _FakeResponse(body)
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
import builtins
def fake_input(*_a, **_kw):
qs = parse_qs(urlparse(captured_url.get("url", "")).query)
state = qs.get("state", [""])[0]
return f"auth-code#{state}"
monkeypatch.setattr(builtins, "input", fake_input)
from agent.anthropic_adapter import run_hermes_oauth_login_pure
result = run_hermes_oauth_login_pure()
assert result is not None, "login should succeed via the console fallback"
assert attempts == [
"https://platform.claude.com/v1/oauth/token",
"https://console.anthropic.com/v1/oauth/token",
], "login must try platform.claude.com first, then fall back to console"
def test_callback_state_mismatch_aborts(monkeypatch, tmp_path, caplog):

View file

@ -40,53 +40,7 @@ class TestOAuthUserAgentPrefix:
assert "claude-code/" in ua, f"Expected claude-code/ in UA, got: {ua}"
assert "claude-cli/" not in ua, f"Must not use claude-cli/ prefix: {ua}"
def test_no_claude_cli_in_source(self):
"""Source file must not contain claude-cli/ UA pattern (blocks OAuth)."""
import inspect
import agent.anthropic_adapter as mod
source = inspect.getsource(mod)
# Allow claude-cli in comments/strings that reference the old behavior
# but not in actual header assignments
lines = source.split("\n")
for i, line in enumerate(lines, 1):
stripped = line.strip()
if "claude-cli/" in stripped and ("User-Agent" in stripped or "user-agent" in stripped):
pytest.fail(
f"Line {i}: claude-cli/ still used in User-Agent header: {stripped}"
)
def test_token_exchange_ua_not_throttled(self):
"""run_hermes_oauth_login_pure must NOT send a throttled token-endpoint UA.
Anthropic 429s both ``claude-cli/`` and ``claude-code/`` UAs at the
token endpoint. The login exchange must use the shared
``_OAUTH_TOKEN_USER_AGENT`` constant (a non-claude-code UA).
"""
import inspect
import agent.anthropic_adapter as mod
try:
source = inspect.getsource(mod.run_hermes_oauth_login_pure)
except AttributeError:
pytest.skip("run_hermes_oauth_login_pure not found")
for i, line in enumerate(source.split("\n"), 1):
stripped = line.strip()
if ("User-Agent" in stripped or "user-agent" in stripped) and (
"claude-cli/" in stripped or "claude-code/" in stripped
):
pytest.fail(
f"Line {i}: throttled UA in token-exchange header: {stripped}"
)
assert "_OAUTH_TOKEN_USER_AGENT" in source, (
"run_hermes_oauth_login_pure should send the shared "
"_OAUTH_TOKEN_USER_AGENT (non-claude-code) on the token endpoint"
)
assert not mod._OAUTH_TOKEN_USER_AGENT.startswith(("claude-code/", "claude-cli/")), (
f"_OAUTH_TOKEN_USER_AGENT must not be a throttled prefix: "
f"{mod._OAUTH_TOKEN_USER_AGENT!r}"
)
def test_token_refresh_ua_not_throttled(self):
"""refresh_anthropic_oauth_pure must NOT send a throttled token-endpoint UA."""

View file

@ -34,11 +34,6 @@ def _assert_clean(block):
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",
@ -47,15 +42,7 @@ class TestSanitizeReplayBlock:
_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

View file

@ -35,19 +35,12 @@ class TestSafeText:
def test_none_becomes_placeholder(self):
assert _safe_text(None) == _EMPTY_TEXT_PLACEHOLDER
def test_empty_string_becomes_placeholder(self):
assert _safe_text("") == _EMPTY_TEXT_PLACEHOLDER
@pytest.mark.parametrize("blank", [" ", "\n", "\t", " \n\t "])
def test_whitespace_only_becomes_placeholder(self, blank):
assert _safe_text(blank) == _EMPTY_TEXT_PLACEHOLDER
def test_real_text_is_kept_verbatim(self):
assert _safe_text("hello") == "hello"
assert _safe_text(" padded ") == " padded "
def test_non_string_is_coerced_then_checked(self):
assert _safe_text(123) == "123"
class TestSanitizeReplayBlockWhitespace:
@ -59,8 +52,6 @@ class TestSanitizeReplayBlockWhitespace:
# cluttered with "(empty)" noise. See _convert_assistant_message.
assert _sanitize_replay_block({"type": "text", "text": " \n"}) is None
def test_empty_text_block_dropped(self):
assert _sanitize_replay_block({"type": "text", "text": ""}) is None
def test_none_text_block_dropped_without_crash(self):
# text=None (invalid upstream payload) must not reach .strip().
@ -87,21 +78,8 @@ class TestConvertAssistantMessageWhitespace:
_assert_no_blank_text(out)
assert _text_blocks(out) == [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]
def test_main_path_coerces_whitespace_string_content(self):
# A whitespace-only string content becomes a whitespace text block that
# the all-empty guard does not catch; the final walk must coerce it.
out = _convert_assistant_message({"role": "assistant", "content": " "})
_assert_no_blank_text(out)
assert _text_blocks(out) == [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]
def test_fully_empty_content_still_gets_placeholder(self):
# Pre-existing behavior preserved.
out = _convert_assistant_message({"role": "assistant", "content": ""})
assert out["content"] == [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]
def test_real_text_content_unchanged(self):
out = _convert_assistant_message({"role": "assistant", "content": "answer"})
assert out["content"] == [{"type": "text", "text": "answer"}]
def test_thinking_block_not_treated_as_text(self):
# Only text blocks are coerced; thinking blocks are left untouched even

View file

@ -42,22 +42,13 @@ class TestComposeUserApiContent:
def test_none_when_nothing_to_inject(self):
assert compose_user_api_content("hello", "", "") is None
def test_none_for_multimodal_content(self):
blocks = [{"type": "text", "text": "hi"}]
assert compose_user_api_content(blocks, "mem", "ctx") is None
def test_composes_memory_block_and_plugin_context(self):
out = compose_user_api_content("hello", "likes tea", "PLUGIN-CTX")
fenced = build_memory_context_block("likes tea")
assert out == "hello" + "\n\n" + fenced + "\n\n" + "PLUGIN-CTX"
def test_plugin_context_only(self):
assert compose_user_api_content("hello", "", "CTX") == "hello\n\nCTX"
def test_deterministic_across_calls(self):
a = compose_user_api_content("hello", "likes tea", "CTX")
b = compose_user_api_content("hello", "likes tea", "CTX")
assert a == b
# ---------------------------------------------------------------------------
@ -84,14 +75,6 @@ class TestSessionDbSidecar:
finally:
db.close()
def test_absent_when_null(self, tmp_path):
db = self._open(tmp_path)
try:
db.append_message("s1", "user", content="hello")
msgs = db.get_messages_as_conversation("s1")
assert "api_content" not in msgs[0]
finally:
db.close()
def test_get_messages_exposes_column(self, tmp_path):
db = self._open(tmp_path)
@ -102,23 +85,6 @@ class TestSessionDbSidecar:
finally:
db.close()
def test_insert_message_rows_carries_sidecar(self, tmp_path):
"""replace_messages (compaction/rewrite flows) preserves the sidecar
from message dicts."""
db = self._open(tmp_path)
try:
db.replace_messages(
"s1",
[
{"role": "user", "content": "hello", "api_content": "hello+ctx"},
{"role": "assistant", "content": "hi"},
],
)
msgs = db.get_messages_as_conversation("s1")
assert msgs[0]["api_content"] == "hello+ctx"
assert "api_content" not in msgs[1]
finally:
db.close()
class TestAutoMigration:
@ -590,32 +556,8 @@ from agent.turn_context import reanchor_current_turn_user_idx
class TestReanchorCurrentTurnUserIdx:
def test_exact_match_beats_later_todo_snapshot(self):
"""compress_context can append a todo-snapshot USER message after the
surviving current-turn copy the anchor must stay on the real turn."""
messages = [
{"role": "assistant", "content": "summary"},
{"role": "user", "content": "hello"},
{"role": "user", "content": "## Current TODOs\n- [ ] thing"},
]
assert reanchor_current_turn_user_idx(messages, "hello") == 1
def test_most_recent_duplicate_wins(self):
messages = [
{"role": "user", "content": "ok"},
{"role": "assistant", "content": "a"},
{"role": "user", "content": "ok"},
]
assert reanchor_current_turn_user_idx(messages, "ok") == 2
def test_falls_back_to_last_user_without_exact_match(self):
"""Merge-summary-into-tail rewrites the content; the trackers still
need a live anchor."""
messages = [
{"role": "user", "content": "[prior context]\nsummary\nhello"},
{"role": "assistant", "content": "a"},
]
assert reanchor_current_turn_user_idx(messages, "hello") == 0
def test_minus_one_when_no_user_message(self):
messages = [{"role": "assistant", "content": "a"}]

View file

@ -36,22 +36,6 @@ def test_is_arcee_trinity_thinking_matches(model: str) -> None:
assert _is_arcee_trinity_thinking(model) is True
@pytest.mark.parametrize(
"model",
[
None,
"",
"trinity-large-preview",
"arcee-ai/trinity-large-preview:free",
"trinity-mini",
"arcee-ai/trinity-mini",
"trinity-large", # prefix-only must not match
"claude-sonnet-4.6",
"gpt-5.4",
],
)
def test_is_arcee_trinity_thinking_rejects_non_matches(model) -> None:
assert _is_arcee_trinity_thinking(model) is False
def test_fixed_temperature_for_trinity_thinking() -> None:
@ -59,15 +43,8 @@ def test_fixed_temperature_for_trinity_thinking() -> None:
assert _fixed_temperature_for_model("arcee-ai/trinity-large-thinking") == 0.5
def test_fixed_temperature_sibling_arcee_models_unaffected() -> None:
# Preview and mini do not pin temperature — caller chooses its default.
assert _fixed_temperature_for_model("trinity-large-preview") is None
assert _fixed_temperature_for_model("trinity-mini") is None
def test_compression_threshold_for_trinity_thinking() -> None:
assert _compression_threshold_for_model("trinity-large-thinking") == 0.75
assert _compression_threshold_for_model("arcee-ai/trinity-large-thinking") == 0.75
def test_compression_threshold_default_none_for_other_models() -> None:
@ -91,34 +68,8 @@ def test_compression_threshold_default_none_for_other_models() -> None:
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"model",
[
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.5-2026-04-23", # dated snapshot
"gpt-5.5-codex-mini", # Codex variant of the 5.5 family (also 272K-capped)
"openai/gpt-5.5", # aggregator-prefixed (still on the codex route)
"GPT-5.5", # case-insensitive
" gpt-5.5 ", # whitespace tolerant
"gpt-5.4", # base 5.4 (272K-capped)
"gpt-5.4-pro", # pro 5.4 variant (272K-capped)
"gpt-5.4-2026-01-01", # dated 5.4 snapshot
"openai/gpt-5.4", # aggregator-prefixed 5.4
],
)
def test_is_codex_gpt54_or_gpt55_matches_on_codex_provider(model: str) -> None:
assert _is_codex_gpt54_or_gpt55(model, "openai-codex") is True
@pytest.mark.parametrize(
"provider",
["openrouter", "openai", "copilot", "openai-api", "", None],
)
def test_is_codex_gpt54_or_gpt55_rejects_non_codex_providers(provider) -> None:
# gpt-5.4 / gpt-5.5 on any non-Codex route keep the larger window.
assert _is_codex_gpt54_or_gpt55("gpt-5.5", provider) is False
assert _is_codex_gpt54_or_gpt55("gpt-5.4", provider) is False
@pytest.mark.parametrize(
@ -140,43 +91,10 @@ def test_compression_threshold_for_codex_gpt55() -> None:
assert _compression_threshold_for_model("openai/gpt-5.5", "openai-codex") == 0.85
def test_compression_threshold_codex_gpt55_other_routes_unaffected() -> None:
# Same slug, different route → no override (keep the user's config value).
assert _compression_threshold_for_model("gpt-5.4", "openrouter") is None
assert _compression_threshold_for_model("gpt-5.4", "openai") is None
assert _compression_threshold_for_model("gpt-5.4", "copilot") is None
assert _compression_threshold_for_model("gpt-5.5", "openrouter") is None
assert _compression_threshold_for_model("gpt-5.5", "openai") is None
assert _compression_threshold_for_model("gpt-5.5", "copilot") is None
assert _compression_threshold_for_model("openai/gpt-5.4") is None # no provider
assert _compression_threshold_for_model("openai/gpt-5.5") is None # no provider
def test_compression_threshold_codex_gpt55_opt_out() -> None:
# Historical flag name still governs both Codex families.
assert (
_compression_threshold_for_model(
"gpt-5.4", "openai-codex", allow_codex_gpt55_autoraise=False
)
is None
)
assert (
_compression_threshold_for_model(
"gpt-5.5", "openai-codex", allow_codex_gpt55_autoraise=False
)
is None
)
def test_compression_threshold_opt_out_does_not_disable_trinity() -> None:
# The opt-out flag is scoped to the Codex gpt-5.5 autoraise; the Arcee
# Trinity override must still apply when the flag is False.
assert (
_compression_threshold_for_model(
"trinity-large-thinking", "openrouter", allow_codex_gpt55_autoraise=False
)
== 0.75
)
# ---------------------------------------------------------------------------
@ -191,26 +109,8 @@ def test_compression_threshold_opt_out_does_not_disable_trinity() -> None:
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"model",
[
"gpt-5.3-codex-spark",
"openai/gpt-5.3-codex-spark", # aggregator-prefixed (still on the codex route)
"GPT-5.3-CODEX-SPARK", # case-insensitive
" gpt-5.3-codex-spark ", # whitespace tolerant
],
)
def test_is_codex_spark_matches_on_codex_provider(model: str) -> None:
assert _is_codex_spark(model, "openai-codex") is True
@pytest.mark.parametrize(
"provider",
["openrouter", "openai", "copilot", "openai-api", "", None],
)
def test_is_codex_spark_rejects_non_codex_providers(provider) -> None:
# spark on any non-Codex route is not a real slug — no override.
assert _is_codex_spark("gpt-5.3-codex-spark", provider) is False
@pytest.mark.parametrize(
@ -227,28 +127,10 @@ def test_is_codex_spark_rejects_non_spark_models(model) -> None:
assert _is_codex_spark(model, "openai-codex") is False
def test_compression_threshold_for_codex_spark() -> None:
assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openai-codex") == 0.70
assert _compression_threshold_for_model("openai/gpt-5.3-codex-spark", "openai-codex") == 0.70
def test_compression_threshold_codex_spark_other_routes_unaffected() -> None:
# Same slug, different route → no override (keep the user's config value).
assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openrouter") is None
assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openai") is None
assert _compression_threshold_for_model("gpt-5.3-codex-spark") is None # no provider
def test_compression_threshold_codex_spark_not_gated_by_gpt55_optout() -> None:
# The spark autoraise is independent of the gpt-5.5 opt-out flag — 128K is
# the model's native window, so 70% is unambiguously correct regardless of
# whether the user opted out of the (artificial-cap) gpt-5.5 autoraise.
assert (
_compression_threshold_for_model(
"gpt-5.3-codex-spark", "openai-codex", allow_codex_gpt55_autoraise=False
)
== 0.70
)
# ── _resolve_compression_threshold (init_agent application logic) ────────────
@ -258,42 +140,12 @@ def test_compression_threshold_codex_spark_not_gated_by_gpt55_optout() -> None:
# user-configured global threshold.
def test_resolve_codex_autoraise_raises_from_default() -> None:
# Default 0.50 global → raised to 0.85, notice emitted.
effective, notice = _resolve_compression_threshold(
0.50, 0.85, model="gpt-5.5", is_codex_autoraise=True
)
assert effective == 0.85
assert notice == {"model": "gpt-5.5", "from": 0.50, "to": 0.85}
def test_resolve_codex_autoraise_never_lowers_higher_threshold() -> None:
# Regression: a user who set compression.threshold above 0.85 must keep it.
# The autoraise previously clobbered it down to 0.85 (and silently, since
# the notice was suppressed when nothing "raised").
effective, notice = _resolve_compression_threshold(
0.90, 0.85, model="gpt-5.5", is_codex_autoraise=True
)
assert effective == 0.90
assert notice is None
def test_resolve_codex_spark_autoraise_never_lowers_higher_threshold() -> None:
# Same never-lower contract for the spark autoraise (0.70).
effective, notice = _resolve_compression_threshold(
0.80, 0.70, model="gpt-5.3-codex-spark", is_codex_autoraise=True
)
assert effective == 0.80
assert notice is None
def test_resolve_codex_autoraise_equal_threshold_is_noop() -> None:
# User already at exactly the raised value: keep it, no notice.
effective, notice = _resolve_compression_threshold(
0.85, 0.85, model="gpt-5.5", is_codex_autoraise=True
)
assert effective == 0.85
assert notice is None
def test_resolve_no_override_keeps_global() -> None:
@ -305,12 +157,3 @@ def test_resolve_no_override_keeps_global() -> None:
assert notice is None
def test_resolve_non_codex_override_applies_unconditionally() -> None:
# Arcee Trinity (0.75) keeps its long-standing unconditional behaviour: it
# applies even when it lowers the user's global value, and never emits the
# codex autoraise notice.
effective, notice = _resolve_compression_threshold(
0.90, 0.75, is_codex_autoraise=False
)
assert effective == 0.75
assert notice is None

View file

@ -195,45 +195,7 @@ class TestCoalescing:
finally:
seq_db.close()
def test_coalesce_unit_rules(self, db):
"""_coalesce_token_deltas merge rules: same route merges, session /
route changes and absolute deltas do not."""
inc = dict(model="m1", billing_provider="p1")
out = db._coalesce_token_deltas([
("a", dict(input_tokens=1, api_call_count=1, **inc)),
("a", dict(input_tokens=2, api_call_count=1, **inc)),
("b", dict(input_tokens=4, api_call_count=1, **inc)),
("a", dict(input_tokens=8, api_call_count=1, **inc)),
("a", dict(input_tokens=16, api_call_count=1, model="m2",
billing_provider="p1")),
("a", dict(input_tokens=32, absolute=True)),
("a", dict(input_tokens=64, absolute=True)),
])
assert [(sid, kw.get("input_tokens")) for sid, kw in out] == [
("a", 3), # merged 1+2
("b", 4), # session change
("a", 8), # session change back
("a", 16), # model change
("a", 32), # absolute never merges
("a", 64),
]
assert out[0][1]["api_call_count"] == 2
def test_coalesce_cost_none_preserved(self, db):
"""An all-None cost run stays None after merging (COALESCE in the
UPDATE must keep the stored value untouched)."""
out = db._coalesce_token_deltas([
("a", dict(input_tokens=1, estimated_cost_usd=None)),
("a", dict(input_tokens=1, estimated_cost_usd=None)),
])
assert len(out) == 1
assert out[0][1]["estimated_cost_usd"] is None
out = db._coalesce_token_deltas([
("a", dict(input_tokens=1, estimated_cost_usd=None)),
("a", dict(input_tokens=1, estimated_cost_usd=0.5)),
])
assert out[0][1]["estimated_cost_usd"] == pytest.approx(0.5)
# =========================================================================
@ -269,63 +231,8 @@ class TestReaderFlush:
assert row["input_tokens"] == 1 + 2 + 3 + 4
assert row["api_call_count"] == 4
def test_flush_empty_queue_is_cheap_noop(self, db):
assert db.flush_token_counts()
# No writer thread was ever started by a bare flush.
assert db._token_writer_thread is None
def test_flush_after_close_drains_on_caller_thread(self, db):
"""After close() stops the writer, a late flush still drains queued
deltas synchronously instead of losing them."""
db.create_session("s-late", "test")
db.flush_token_counts()
db._stop_token_writer() # simulate a stopped writer with the conn open
db._token_queue.append(("s-late", dict(input_tokens=9, api_call_count=1)))
assert db.flush_token_counts()
assert _totals(db, "s-late")["input_tokens"] == 9
def test_flush_waits_for_stop_flagged_live_writer(self, db):
"""A stop-flagged but still-running writer owns the queue: flush must
wait for it (its loop drains before exiting), never drain on the
caller's thread — that would commit newer deltas before the writer's
in-flight older batch and could return True with that batch
unapplied."""
db.create_session("s-stop", "test")
applied = []
gate = threading.Event()
first_apply_started = threading.Event()
original = db.update_token_counts
def gated(session_id, **kwargs):
applied.append(kwargs.get("input_tokens"))
if len(applied) == 1:
first_apply_started.set()
assert gate.wait(timeout=10)
return original(session_id, **kwargs)
db.update_token_counts = gated
try:
db.queue_token_counts("s-stop", input_tokens=1, api_call_count=1)
assert first_apply_started.wait(timeout=10)
# close() has set the stop flag but the writer is mid-apply.
db._token_writer_stop = True
db._token_queue.append(
("s-stop", dict(input_tokens=2, api_call_count=1))
)
# The writer is alive, so flush waits — timing out, NOT applying
# the newer delta on this thread ahead of the in-flight batch.
assert db.flush_token_counts(timeout=0.3) is False
assert applied == [1]
gate.set()
# Once released, the stop-flagged writer drains the queue itself
# before exiting, preserving enqueue order.
assert db.flush_token_counts()
finally:
db.update_token_counts = original
assert applied == [1, 2]
assert _totals(db, "s-stop")["input_tokens"] == 3
def test_concurrent_flush_waits_for_caller_drain(self, db):
"""The dead-writer caller-drain claims busy: a second flush must not
@ -369,22 +276,6 @@ class TestReaderFlush:
assert _totals(db, "s-cc")["input_tokens"] == 4
def test_enqueue_after_writer_stop_applies_synchronously(self, db):
"""Once the writer is stopped for good, queue_token_counts falls back
to the synchronous path instead of parking deltas on a queue no
writer will ever drain."""
db.create_session("s-sync", "test")
db.queue_token_counts("s-sync", input_tokens=1, api_call_count=1)
db._stop_token_writer() # writer dead, connection still open
db.queue_token_counts("s-sync", input_tokens=2, api_call_count=1)
# Applied inline — nothing queued, no writer restarted.
assert not db._token_queue
assert db._token_writer_thread is None or not db._token_writer_thread.is_alive()
totals = _totals(db, "s-sync")
assert totals["input_tokens"] == 3
assert totals["api_call_count"] == 2
def test_enqueue_after_close_raises_at_call_site(self, tmp_path):
"""After close() the synchronous fallback surfaces the failure to the
@ -446,30 +337,7 @@ class TestRouteSwitchBarrier:
class TestDurability:
def test_close_drains_queue(self, tmp_path):
"""close() drains queued deltas before closing the connection, so a
clean shutdown loses nothing."""
db_path = tmp_path / "drain.db"
db = SessionDB(db_path=db_path)
db.create_session("s-d", "test")
for i in range(5):
db.queue_token_counts("s-d", input_tokens=10, api_call_count=1)
db.close()
reopened = SessionDB(db_path=db_path)
try:
totals = _totals(reopened, "s-d")
assert totals["input_tokens"] == 50
assert totals["api_call_count"] == 5
finally:
reopened.close()
def test_atexit_drain_is_idempotent_and_never_raises(self, db):
db.create_session("s-x", "test")
db.queue_token_counts("s-x", input_tokens=3, api_call_count=1)
db._drain_token_queue_at_exit()
db._drain_token_queue_at_exit() # second call: writer already stopped
assert _totals(db, "s-x")["input_tokens"] == 3
def test_close_unregisters_atexit_hook(self, tmp_path):
"""close() must unregister the atexit drain hook: it holds a strong
@ -531,37 +399,6 @@ class TestDurability:
class TestWriterFailure:
def test_apply_failure_logs_and_does_not_raise(self, db, caplog):
"""A failing UPDATE is logged by the writer; enqueue/flush never
raise into the turn, and the writer survives to apply later deltas."""
db.create_session("s-f", "test")
original = db.update_token_counts
boom = {"raise": True}
def flaky(session_id, **kwargs):
if boom["raise"]:
raise sqlite3.OperationalError("database is locked")
return original(session_id, **kwargs)
db.update_token_counts = flaky
try:
with caplog.at_level("WARNING", logger="hermes_state"):
db.queue_token_counts("s-f", input_tokens=5, api_call_count=1)
assert db.flush_token_counts()
assert any(
"async token accounting" in rec.getMessage()
for rec in caplog.records
)
# Writer thread survived the failure and keeps applying.
boom["raise"] = False
db.queue_token_counts("s-f", input_tokens=7, api_call_count=1)
assert db.flush_token_counts()
finally:
db.update_token_counts = original
assert _totals(db, "s-f")["input_tokens"] == 7
def test_coalesce_failure_falls_back_to_raw_batch(self, db, caplog):
"""A coalescing bug must never kill the writer: the batch is applied
@ -589,25 +426,6 @@ class TestWriterFailure:
assert totals["input_tokens"] == 7
assert totals["api_call_count"] == 2
def test_dead_writer_respawns_on_next_enqueue(self, db):
"""If the writer thread ever dies unexpectedly, the next enqueue
must respawn it instead of parking deltas on a queue forever."""
db.create_session("s-respawn", "test")
db.queue_token_counts("s-respawn", input_tokens=1, api_call_count=1)
assert db.flush_token_counts()
first = db._token_writer_thread
assert first is not None
# Simulate an unexpected writer death: a finished dummy thread.
dead = threading.Thread(target=lambda: None)
dead.start()
dead.join()
db._token_writer_thread = dead
db.queue_token_counts("s-respawn", input_tokens=2, api_call_count=1)
assert db._token_writer_thread is not dead
assert db.flush_token_counts()
assert _totals(db, "s-respawn")["input_tokens"] == 3
def test_stop_drain_claims_busy_before_clearing_queue(self, db):
"""_stop_token_writer's leftover drain must follow the same

View file

@ -70,36 +70,7 @@ class TestSafeScheduleThreadsafe:
loop.call_soon_threadsafe(loop.stop)
loop.close()
def test_closed_loop_returns_none_and_closes_coroutine(self):
loop = asyncio.new_event_loop()
loop.close()
async def _sample():
return "ok"
coro = _sample()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = safe_schedule_threadsafe(coro, loop)
del coro
gc.collect()
assert result is None
assert _no_unawaited_warnings(caught, coro_name='_sample')
def test_none_loop_returns_none_and_closes_coroutine(self):
async def _sample():
return "ok"
coro = _sample()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = safe_schedule_threadsafe(coro, None)
del coro
gc.collect()
assert result is None
assert _no_unawaited_warnings(caught, coro_name='_sample')
def test_scheduling_exception_closes_coroutine(self):
"""If run_coroutine_threadsafe raises, close the coroutine and return None."""
@ -125,31 +96,4 @@ class TestSafeScheduleThreadsafe:
finally:
loop.close()
def test_logs_at_specified_level(self, caplog):
import logging
loop = asyncio.new_event_loop()
loop.close()
async def _sample():
return None
custom = logging.getLogger("test_async_utils")
with caplog.at_level(logging.WARNING, logger="test_async_utils"):
result = safe_schedule_threadsafe(
_sample(), loop,
logger=custom,
log_message="custom-msg",
log_level=logging.WARNING,
)
assert result is None
assert any("custom-msg" in rec.message for rec in caplog.records)
def test_non_coroutine_arg_does_not_crash(self):
"""Defensive: even if the caller hands us something weird, don't blow up."""
loop = asyncio.new_event_loop()
loop.close()
# Pass a non-coroutine sentinel
result = safe_schedule_threadsafe("not-a-coroutine", loop) # type: ignore[arg-type]
assert result is None

View file

@ -88,15 +88,7 @@ class TestAuxProgressHook:
_notify_aux_progress() # outside — must not tick
assert ticks == [1]
def test_none_hook_is_noop_passthrough(self):
with aux_progress_hook(None):
_notify_aux_progress() # must not raise
def test_hook_exception_is_swallowed(self):
def _boom():
raise RuntimeError("hook blew up")
with aux_progress_hook(_boom):
_notify_aux_progress() # must not raise
def test_hook_is_thread_local(self):
ticks = []
@ -119,12 +111,6 @@ class TestAuxProgressHook:
# ---------------------------------------------------------------------------
class TestCreateWithProgress:
def test_no_hook_means_plain_nonstreaming_call(self):
client = _FakeClient(response=_COMPLETE)
result = _create_with_progress(client, {"model": "m1", "messages": []})
assert result is _COMPLETE
assert len(client.calls) == 1
assert "stream" not in client.calls[0]
def test_hook_upgrades_to_streaming_and_ticks_per_chunk(self):
chunks = [
@ -163,25 +149,7 @@ class TestCreateWithProgress:
assert client.calls[0].get("stream") is True
assert "stream" not in client.calls[1]
def test_auth_error_propagates_without_nonstreaming_retry(self):
class _FakeAuthError(Exception):
status_code = 401
client = _FakeClient(stream_error=_FakeAuthError("Error code: 401 - unauthorized"))
with aux_progress_hook(lambda: None):
with pytest.raises(_FakeAuthError):
_create_with_progress(client, {"model": "m1", "messages": []})
assert len(client.calls) == 1 # no silent non-streaming retry
def test_shim_returning_complete_response_passes_through(self):
# Adapters may ignore stream=True and hand back a full response.
class _ShimClient(_FakeClient):
def _create(self, **kwargs):
self.calls.append(kwargs)
return _COMPLETE
client = _ShimClient()
with aux_progress_hook(lambda: None):
result = _create_with_progress(client, {"model": "m1", "messages": []})
assert result is _COMPLETE
# ---------------------------------------------------------------------------
@ -210,13 +178,6 @@ class TestAggregateChatStream:
assert tool_calls[0].function.arguments == '{"a": 1}'
assert result.choices[0].finish_reason == "tool_calls"
def test_total_ceiling_kills_trickle_stream_as_timeout(self):
def _trickle():
while True:
time.sleep(0.01)
yield _chunk(content="x")
with pytest.raises(TimeoutError, match="timed out"):
_aggregate_chat_stream(_trickle(), total_ceiling=0.05)
def test_stream_close_is_called(self):
closed = []
@ -232,11 +193,6 @@ class TestAggregateChatStream:
assert result.choices[0].message.content == "ok"
assert closed == [True]
def test_empty_choices_chunks_are_skipped(self):
empty = SimpleNamespace(id="c", model="m", choices=[], usage=None)
chunks = [empty, _chunk(content="ok", finish_reason="stop")]
result = _aggregate_chat_stream(iter(chunks))
assert result.choices[0].message.content == "ok"
# ---------------------------------------------------------------------------
@ -247,8 +203,6 @@ class TestStreamCeiling:
def test_floor_applies_to_small_timeouts(self):
assert _aux_stream_total_ceiling(30) == 600.0
def test_multiplier_wins_for_large_timeouts(self):
assert _aux_stream_total_ceiling(300) == 1200.0
def test_none_timeout_gets_floor(self):
assert _aux_stream_total_ceiling(None) == 600.0
@ -281,10 +235,6 @@ class TestFenceProgress:
# ---------------------------------------------------------------------------
class TestProviderRequiresStream:
def test_tencent_copilot_is_stream_only(self):
assert _provider_requires_stream(
"custom", "https://copilot.tencent.com/v1"
) is True
def test_normal_endpoints_are_not(self):
assert _provider_requires_stream(
@ -305,14 +255,6 @@ class TestProviderRequiresStream:
"custom", "https://other.example.com/v1"
) is False
def test_config_read_failure_fails_open_to_non_streaming(self):
with patch(
"hermes_cli.config.load_config",
side_effect=RuntimeError("config broken"),
):
assert _provider_requires_stream(
"custom", "https://other.example.com/v1"
) is False
class TestForceStream:

File diff suppressed because it is too large Load diff

View file

@ -103,21 +103,6 @@ class TestAuxAzureFoundryApiKey:
assert isinstance(client, _OpenAI)
assert client.api_key == "sk-azure-static-key"
def test_codex_responses_wraps_in_codex_aux_client(self, monkeypatch, patch_load_config):
from agent.auxiliary_client import _try_azure_foundry, CodexAuxiliaryClient
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"default": "gpt-5.4-mini",
})
# GPT-5.x → runtime auto-upgrades to codex_responses
client, resolved = _try_azure_foundry(model="gpt-5.4-mini")
assert resolved == "gpt-5.4-mini"
assert isinstance(client, CodexAuxiliaryClient)
assert client.api_key == "sk-azure-static-key"
def test_no_key_returns_none(self, monkeypatch, patch_load_config):
from agent.auxiliary_client import _try_azure_foundry
@ -133,21 +118,6 @@ class TestAuxAzureFoundryApiKey:
assert client is None
assert resolved is None
def test_no_model_returns_none(self, monkeypatch, patch_load_config):
"""Azure has no fallback aux model — fail soft so the auto chain
can try other providers."""
from agent.auxiliary_client import _try_azure_foundry
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
# No default model
})
client, resolved = _try_azure_foundry()
assert client is None
assert resolved is None
# ---------------------------------------------------------------------------

View file

@ -122,68 +122,4 @@ class TestTryAnthropicBaseUrlHostValidation:
f"Non-Anthropic host must not be applied. Got: {actual!r}"
)
def test_empty_base_url_falls_back_to_default(self, tmp_path, monkeypatch):
"""Empty model.base_url must not crash and must fall back to default."""
import yaml
from agent.auxiliary_client import _try_anthropic
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"model": {
"provider": "anthropic",
"model": "claude-haiku-4-5-20251001",
"base_url": "",
}
}))
with (
patch(
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
),
patch(
"agent.anthropic_adapter.resolve_anthropic_token",
return_value="***",
),
patch(
"agent.anthropic_adapter.build_anthropic_client"
) as mock_build,
):
mock_build.return_value = MagicMock()
client, _model = _try_anthropic()
assert client is not None
actual = _extract_base_url_passed_to_build(mock_build)
assert actual == "https://api.anthropic.com"
def test_anthropic_host_with_path_is_preserved(self, tmp_path, monkeypatch):
"""api.anthropic.com with a path suffix must still pass the host check."""
import yaml
from agent.auxiliary_client import _try_anthropic
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"model": {
"provider": "anthropic",
"model": "claude-haiku-4-5-20251001",
"base_url": "https://api.anthropic.com/v1/messages",
}
}))
with (
patch(
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
),
patch(
"agent.anthropic_adapter.resolve_anthropic_token",
return_value="***",
),
patch(
"agent.anthropic_adapter.build_anthropic_client"
) as mock_build,
):
mock_build.return_value = MagicMock()
client, _model = _try_anthropic()
assert client is not None
actual = _extract_base_url_passed_to_build(mock_build)
assert actual == "https://api.anthropic.com/v1/messages", (
f"Anthropic host with path must be preserved. Got: {actual!r}"
)

View file

@ -40,43 +40,8 @@ def test_create_openai_client_routes_via_env_proxy(mock_openai, monkeypatch):
http_client.close()
@patch("agent.auxiliary_client.OpenAI")
def test_create_openai_client_no_proxy_when_env_unset(mock_openai, monkeypatch):
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
"https_proxy", "http_proxy", "all_proxy", "NO_PROXY", "no_proxy"):
monkeypatch.delenv(key, raising=False)
_create_openai_client(
api_key="test-key",
base_url="https://litellm.internal.example.com/v1",
)
http_client = mock_openai.call_args.kwargs.get("http_client")
assert isinstance(http_client, httpx.Client)
assert "HTTPProxy" not in _pool_types(http_client)
http_client.close()
@patch("agent.auxiliary_client.OpenAI")
def test_create_openai_client_ignores_macos_system_proxy(mock_openai, monkeypatch):
"""System proxy from getproxies() must not apply when env vars are unset."""
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
"https_proxy", "http_proxy", "all_proxy", "NO_PROXY", "no_proxy"):
monkeypatch.delenv(key, raising=False)
with patch(
"urllib.request.getproxies",
return_value={"http": "http://127.0.0.1:7897", "https": "http://127.0.0.1:7897"},
):
_create_openai_client(
api_key="test-key",
base_url="https://litellm.internal.example.com/v1",
)
http_client = mock_openai.call_args.kwargs.get("http_client")
assert isinstance(http_client, httpx.Client)
assert "HTTPProxy" not in _pool_types(http_client)
http_client.close()
def test_get_proxy_for_base_url_respects_no_proxy(monkeypatch):
@ -90,9 +55,3 @@ def test_get_proxy_for_base_url_respects_no_proxy(monkeypatch):
assert _get_proxy_for_base_url("https://api.openai.com/v1") == "http://127.0.0.1:7897"
def test_openai_http_client_kwargs_async_mode():
kwargs = _openai_http_client_kwargs(
"https://litellm.internal.example.com/v1",
async_mode=True,
)
assert isinstance(kwargs["http_client"], httpx.AsyncClient)

View file

@ -32,31 +32,10 @@ def test_build_keepalive_http_client_forwards_verify_context(clean_tls_env):
assert client._transport._pool._ssl_context is ctx
def test_build_keepalive_http_client_verify_false_disables_hostname_check(clean_tls_env):
client = build_keepalive_http_client("https://ollama.example.com/v1", verify=False)
assert isinstance(client, httpx.Client)
assert client._transport._pool._ssl_context.check_hostname is False
def test_build_keepalive_http_client_default_verify_true(clean_tls_env):
client = build_keepalive_http_client("https://ollama.example.com/v1")
assert isinstance(client, httpx.Client)
def test_resolve_aux_verify_uses_per_provider_ssl_ca_cert(clean_tls_env, monkeypatch):
"""_resolve_aux_verify should mirror the main-client resolution for a matched base_url."""
import hermes_cli.config as cfg
from agent import auxiliary_client
# get_custom_provider_tls_settings is imported inside the function from
# hermes_cli.config, so patch it at the source module.
monkeypatch.setattr(
cfg,
"get_custom_provider_tls_settings",
lambda *a, **k: {"ssl_ca_cert": certifi.where()},
)
verify = auxiliary_client._resolve_aux_verify("https://ollama.example.com/v1")
assert isinstance(verify, ssl.SSLContext)
def test_resolve_aux_verify_ssl_verify_false(clean_tls_env, monkeypatch):
@ -71,9 +50,3 @@ def test_resolve_aux_verify_ssl_verify_false(clean_tls_env, monkeypatch):
assert auxiliary_client._resolve_aux_verify("https://ollama.example.com/v1") is False
def test_resolve_aux_verify_no_match_defaults_true(clean_tls_env, monkeypatch):
import hermes_cli.config as cfg
from agent import auxiliary_client
monkeypatch.setattr(cfg, "get_custom_provider_tls_settings", lambda *a, **k: {})
assert auxiliary_client._resolve_aux_verify("https://openrouter.ai/api/v1") is True

View file

@ -49,34 +49,10 @@ class TestIsAuthErrorXaiOauth403:
exc.status_code = 403
assert self.is_auth_error(exc) is False
def test_401_status_code_is_auth_error(self):
"""Existing 401 detection still works."""
exc = Exception("Unauthorized")
exc.status_code = 401
assert self.is_auth_error(exc) is True
def test_401_string_is_auth_error(self):
"""Existing string-based 401 detection still works."""
exc = Exception("Error code: 401 - Unauthorized")
assert self.is_auth_error(exc) is True
def test_authentication_error_class_is_auth_error(self):
"""Existing AuthenticationError class detection still works."""
exc_type = type("AuthenticationError", (Exception,), {})
exc = exc_type("auth failure")
assert self.is_auth_error(exc) is True
def test_permission_denied_without_bad_credentials_is_not_auth_error(self):
"""403 PermissionDenied without bad-credentials should not be auth."""
exc = Exception("Error code: 403 - Permission denied")
exc.status_code = 403
assert self.is_auth_error(exc) is False
def test_500_is_not_auth_error(self):
"""Server errors are not auth errors."""
exc = Exception("Error code: 500 - Internal server error")
exc.status_code = 500
assert self.is_auth_error(exc) is False
def test_unauthenticated_without_bad_credentials_is_not_auth_error(self):
"""'unauthenticated' alone (without 'bad-credentials') should not match."""

View file

@ -93,22 +93,6 @@ class TestCompressionTimeoutFloorSync:
"the too-low config timeout must not pass through unchanged"
)
def test_explicit_per_call_timeout_is_not_floored(self):
"""Layer 3: an explicit per-call ``timeout=`` override is honoured
even when it is below the floor."""
client = _client_sync()
explicit = 60.0
p1, p2, p3, p4 = _patches(client, task_timeout=COMPRESSION_CONFIG_TIMEOUT)
with p1, p2, p3, p4:
call_llm(
task="compression",
messages=[{"role": "user", "content": "x"}],
timeout=explicit,
)
timeout = client.chat.completions.create.call_args.kwargs["timeout"]
assert timeout == explicit, (
f"explicit per-call timeout {explicit} must not be floored, got {timeout}"
)
def test_non_compression_task_is_not_floored(self):
"""Layer 4: only ``compression`` gets the floor; another auxiliary
@ -126,21 +110,6 @@ class TestCompressionTimeoutFloorSync:
f"non-compression task timeout must stay {low}, got {timeout}"
)
def test_higher_config_timeout_is_not_lowered(self):
"""Layer 5: the floor is a minimum — a config value already above it
is kept unchanged (``max`` semantics)."""
client = _client_sync()
high = 600.0
p1, p2, p3, p4 = _patches(client, task_timeout=high)
with p1, p2, p3, p4:
call_llm(
task="compression",
messages=[{"role": "user", "content": "x"}],
)
timeout = client.chat.completions.create.call_args.kwargs["timeout"]
assert timeout == high, (
f"config timeout {high} above the floor must be unchanged, got {timeout}"
)
class TestCompressionTimeoutFloorAsync:

View file

@ -73,17 +73,6 @@ def _run_auxiliary_bridge(config_dict, monkeypatch):
class TestAuxiliaryConfigBridge:
"""Verify the config.yaml → env var bridging logic used by CLI and gateway."""
def test_vision_provider_bridged(self, monkeypatch):
config = {
"auxiliary": {
"vision": {"provider": "openrouter", "model": ""},
"web_extract": {"provider": "auto", "model": ""},
}
}
_run_auxiliary_bridge(config, monkeypatch)
assert os.environ.get("AUXILIARY_VISION_PROVIDER") == "openrouter"
# auto should not be set
assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") is None
def test_vision_model_bridged(self, monkeypatch):
config = {
@ -106,46 +95,9 @@ class TestAuxiliaryConfigBridge:
assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") == "nous"
assert os.environ.get("AUXILIARY_WEB_EXTRACT_MODEL") == "gemini-2.5-flash"
def test_direct_endpoint_bridged(self, monkeypatch):
config = {
"auxiliary": {
"vision": {
"base_url": "http://localhost:1234/v1",
"api_key": "local-key",
"model": "qwen2.5-vl",
}
}
}
_run_auxiliary_bridge(config, monkeypatch)
assert os.environ.get("AUXILIARY_VISION_BASE_URL") == "http://localhost:1234/v1"
assert os.environ.get("AUXILIARY_VISION_API_KEY") == "local-key"
assert os.environ.get("AUXILIARY_VISION_MODEL") == "qwen2.5-vl"
def test_empty_values_not_bridged(self, monkeypatch):
config = {
"auxiliary": {
"vision": {"provider": "auto", "model": ""},
}
}
_run_auxiliary_bridge(config, monkeypatch)
assert os.environ.get("AUXILIARY_VISION_PROVIDER") is None
assert os.environ.get("AUXILIARY_VISION_MODEL") is None
def test_missing_auxiliary_section_safe(self, monkeypatch):
"""Config without auxiliary section should not crash."""
config = {"model": {"default": "test-model"}}
_run_auxiliary_bridge(config, monkeypatch)
assert os.environ.get("AUXILIARY_VISION_PROVIDER") is None
def test_non_dict_task_config_ignored(self, monkeypatch):
"""Malformed task config (e.g. string instead of dict) is safely ignored."""
config = {
"auxiliary": {
"vision": "openrouter", # should be a dict
}
}
_run_auxiliary_bridge(config, monkeypatch)
assert os.environ.get("AUXILIARY_VISION_PROVIDER") is None
def test_mixed_tasks(self, monkeypatch):
config = {
@ -160,34 +112,8 @@ class TestAuxiliaryConfigBridge:
assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") is None
assert os.environ.get("AUXILIARY_WEB_EXTRACT_MODEL") == "custom-llm"
def test_all_tasks_with_overrides(self, monkeypatch):
config = {
"auxiliary": {
"vision": {"provider": "openrouter", "model": "google/gemini-2.5-flash"},
"web_extract": {"provider": "nous", "model": "gemini-3-flash"},
}
}
_run_auxiliary_bridge(config, monkeypatch)
assert os.environ.get("AUXILIARY_VISION_PROVIDER") == "openrouter"
assert os.environ.get("AUXILIARY_VISION_MODEL") == "google/gemini-2.5-flash"
assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") == "nous"
assert os.environ.get("AUXILIARY_WEB_EXTRACT_MODEL") == "gemini-3-flash"
def test_whitespace_in_values_stripped(self, monkeypatch):
config = {
"auxiliary": {
"vision": {"provider": " openrouter ", "model": " my-model "},
}
}
_run_auxiliary_bridge(config, monkeypatch)
assert os.environ.get("AUXILIARY_VISION_PROVIDER") == "openrouter"
assert os.environ.get("AUXILIARY_VISION_MODEL") == "my-model"
def test_empty_auxiliary_dict_safe(self, monkeypatch):
config = {"auxiliary": {}}
_run_auxiliary_bridge(config, monkeypatch)
assert os.environ.get("AUXILIARY_VISION_PROVIDER") is None
assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") is None
# ── Gateway bridge parity test ───────────────────────────────────────────────

View file

@ -23,33 +23,6 @@ from unittest.mock import MagicMock, patch
class TestResolveAutoMainFirst:
"""_resolve_auto() must prefer main provider + main model for every user."""
def test_openrouter_main_uses_main_model_for_aux(self, monkeypatch):
"""OpenRouter main user → aux uses their picked OR model, not Gemini Flash."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
with patch(
"agent.auxiliary_client._read_main_provider",
return_value="openrouter",
), patch(
"agent.auxiliary_client._read_main_model",
return_value="anthropic/claude-sonnet-4.6",
), patch(
"agent.auxiliary_client.resolve_provider_client"
) as mock_resolve:
mock_client = MagicMock()
mock_resolve.return_value = (mock_client, "anthropic/claude-sonnet-4.6")
from agent.auxiliary_client import _resolve_auto
client, model = _resolve_auto()
assert client is mock_client
assert model == "anthropic/claude-sonnet-4.6"
# Verify it asked resolve_provider_client for the MAIN provider+model,
# not a fallback-chain provider
mock_resolve.assert_called_once()
assert mock_resolve.call_args.args[0] == "openrouter"
assert mock_resolve.call_args.args[1] == "anthropic/claude-sonnet-4.6"
def test_moa_main_resolves_aux_to_aggregator(self, monkeypatch, tmp_path):
"""MoA main user → aux runs on the aggregator slot, NOT the preset name.
@ -111,72 +84,8 @@ class TestResolveAutoMainFirst:
# aggregator's base_url.
assert mock_resolve.call_args.kwargs.get("explicit_base_url") in (None, "")
def test_nous_main_uses_main_model_for_aux(self, monkeypatch):
"""Nous Portal main user → aux uses their picked Nous model, not free-tier MiMo."""
# No OPENROUTER_API_KEY → ensures if main failed we'd fall to chain
with patch(
"agent.auxiliary_client._read_main_provider", return_value="nous",
), patch(
"agent.auxiliary_client._read_main_model",
return_value="anthropic/claude-opus-4.6",
), patch(
"agent.auxiliary_client.resolve_provider_client"
) as mock_resolve:
mock_client = MagicMock()
mock_resolve.return_value = (mock_client, "anthropic/claude-opus-4.6")
from agent.auxiliary_client import _resolve_auto
client, model = _resolve_auto()
assert client is mock_client
assert model == "anthropic/claude-opus-4.6"
assert mock_resolve.call_args.args[0] == "nous"
def test_non_aggregator_main_still_uses_main(self, monkeypatch):
"""Non-aggregator main (DeepSeek) → unchanged behavior, main model used."""
monkeypatch.setenv("DEEPSEEK_API_KEY", "ds-test")
with patch(
"agent.auxiliary_client._read_main_provider", return_value="deepseek",
), patch(
"agent.auxiliary_client._read_main_model", return_value="deepseek-chat",
), patch(
"agent.auxiliary_client.resolve_provider_client"
) as mock_resolve:
mock_client = MagicMock()
mock_resolve.return_value = (mock_client, "deepseek-chat")
from agent.auxiliary_client import _resolve_auto
client, model = _resolve_auto()
assert client is mock_client
assert model == "deepseek-chat"
assert mock_resolve.call_args.args[0] == "deepseek"
def test_main_unavailable_falls_through_to_chain(self, monkeypatch):
"""Main provider with no working client → fall back to aux chain."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
chain_client = MagicMock()
with patch(
"agent.auxiliary_client._read_main_provider", return_value="anthropic",
), patch(
"agent.auxiliary_client._read_main_model", return_value="claude-opus",
), patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(None, None), # main provider has no client
), patch(
"agent.auxiliary_client._try_openrouter",
return_value=(chain_client, "google/gemini-3-flash-preview"),
):
from agent.auxiliary_client import _resolve_auto
client, model = _resolve_auto()
assert client is chain_client
assert model == "google/gemini-3-flash-preview"
def test_main_unavailable_uses_task_fallback_chain_before_builtin_chain(self):
"""Auto aux resolution honors auxiliary.<task>.fallback_chain before built-ins."""
@ -207,77 +116,8 @@ class TestResolveAutoMainFirst:
mock_main_chain.assert_not_called()
mock_openrouter.assert_not_called()
def test_main_unavailable_uses_main_fallback_chain_before_builtin_chain(self):
"""Auto aux resolution honors top-level fallback_providers before built-ins."""
main_fallback_client = MagicMock()
with patch(
"agent.auxiliary_client._read_main_provider", return_value="nvidia",
), patch(
"agent.auxiliary_client._read_main_model", return_value="qwen/qwen3.5-122b-a10b",
), patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(None, None), # main provider has no client
), patch(
"agent.auxiliary_client._try_configured_fallback_chain",
return_value=(None, None, ""),
), patch(
"agent.auxiliary_client._try_main_fallback_chain",
return_value=(main_fallback_client, "inclusionai/ring-2.6-1t:free", "openrouter"),
) as mock_main_chain, patch(
"agent.auxiliary_client._try_openrouter",
) as mock_openrouter:
from agent.auxiliary_client import _resolve_auto
client, model = _resolve_auto(task="title_generation")
assert client is main_fallback_client
assert model == "inclusionai/ring-2.6-1t:free"
mock_main_chain.assert_called_once_with(
"title_generation", "nvidia", reason="main provider unavailable")
mock_openrouter.assert_not_called()
def test_no_main_config_uses_chain_directly(self):
"""No main provider configured → skip step 1, use chain (no regression)."""
chain_client = MagicMock()
with patch(
"agent.auxiliary_client._read_main_provider", return_value="",
), patch(
"agent.auxiliary_client._read_main_model", return_value="",
), patch(
"agent.auxiliary_client._try_openrouter",
return_value=(chain_client, "google/gemini-3-flash-preview"),
):
from agent.auxiliary_client import _resolve_auto
client, model = _resolve_auto()
assert client is chain_client
def test_runtime_override_wins_over_config(self, monkeypatch):
"""main_runtime kwarg overrides config-read main provider/model."""
with patch(
"agent.auxiliary_client._read_main_provider",
return_value="openrouter",
), patch(
"agent.auxiliary_client._read_main_model", return_value="config-model",
), patch(
"agent.auxiliary_client.resolve_provider_client"
) as mock_resolve:
mock_resolve.return_value = (MagicMock(), "runtime-model")
from agent.auxiliary_client import _resolve_auto
_resolve_auto(main_runtime={
"provider": "anthropic",
"model": "runtime-model",
"base_url": "",
"api_key": "",
"api_mode": "",
})
# Runtime override wins
assert mock_resolve.call_args.args[0] == "anthropic"
assert mock_resolve.call_args.args[1] == "runtime-model"
def test_resolve_provider_auto_returns_runtime_model_not_stale_config_default(self):
"""Blank auto aux requests must not pair a stale config model with live fallback provider."""
@ -375,73 +215,8 @@ class TestResolveVisionMainFirst:
assert mock_resolve.call_args.args[1] == "anthropic/claude-sonnet-4.6"
assert mock_resolve.call_args.kwargs.get("is_vision") is True
def test_nous_main_vision_uses_paid_nous_vision_backend(self):
"""Paid Nous main → aux vision uses the dedicated Nous vision backend."""
with patch(
"agent.auxiliary_client._read_main_provider", return_value="nous",
), patch(
"agent.auxiliary_client._read_main_model",
return_value="openai/gpt-5",
), patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", None, None, None, None),
), patch(
"agent.auxiliary_client._resolve_strict_vision_backend",
return_value=(MagicMock(), "google/gemini-3-flash-preview"),
):
from agent.auxiliary_client import resolve_vision_provider_client
provider, client, model = resolve_vision_provider_client()
assert provider == "nous"
assert client is not None
assert model == "google/gemini-3-flash-preview"
def test_nous_main_vision_uses_free_tier_nous_vision_backend(self):
"""Free-tier Nous main → aux vision uses MiMo omni, not the text main model."""
with patch(
"agent.auxiliary_client._read_main_provider", return_value="nous",
), patch(
"agent.auxiliary_client._read_main_model",
return_value="xiaomi/mimo-v2-pro",
), patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", None, None, None, None),
), patch(
"agent.auxiliary_client._resolve_strict_vision_backend",
return_value=(MagicMock(), "xiaomi/mimo-v2-omni"),
):
from agent.auxiliary_client import resolve_vision_provider_client
provider, client, model = resolve_vision_provider_client()
assert provider == "nous"
assert client is not None
assert model == "xiaomi/mimo-v2-omni"
def test_exotic_provider_with_vision_override_preserved(self):
"""xiaomi → mimo-v2.5 override still wins over main_model."""
with patch(
"agent.auxiliary_client._read_main_provider", return_value="xiaomi",
), patch(
"agent.auxiliary_client._read_main_model",
return_value="mimo-v2-pro", # text model
), patch(
"agent.auxiliary_client.resolve_provider_client"
) as mock_resolve, patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", None, None, None, None),
):
mock_resolve.return_value = (MagicMock(), "mimo-v2.5")
from agent.auxiliary_client import resolve_vision_provider_client
provider, client, model = resolve_vision_provider_client()
assert provider == "xiaomi"
# Should use mimo-v2.5 (vision override), not mimo-v2-pro (text main)
assert mock_resolve.call_args.args[1] == "mimo-v2.5"
assert mock_resolve.call_args.kwargs.get("is_vision") is True
def test_copilot_vision_sets_vision_header(self, monkeypatch):
"""Copilot vision requests include the header required for vision routing."""
@ -523,52 +298,7 @@ class TestResolveVisionMainFirst:
assert captured == {"is_agent_turn": True, "is_vision": False}
assert "default_headers" not in mock_openai.call_args.kwargs
def test_main_unavailable_vision_falls_through_to_aggregators(self):
"""Main provider fails → fall back to OpenRouter/Nous strict backends."""
fallback_client = MagicMock()
with patch(
"agent.auxiliary_client._read_main_provider", return_value="deepseek",
), patch(
"agent.auxiliary_client._read_main_model", return_value="deepseek-chat",
), patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(None, None),
), patch(
"agent.auxiliary_client._resolve_strict_vision_backend",
return_value=(fallback_client, "google/gemini-3-flash-preview"),
), patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", None, None, None, None),
):
from agent.auxiliary_client import resolve_vision_provider_client
provider, client, model = resolve_vision_provider_client()
assert client is fallback_client
assert provider in {"openrouter", "nous"}
def test_explicit_provider_override_still_wins(self):
"""Explicit config override bypasses main-first policy."""
with patch(
"agent.auxiliary_client._read_main_provider", return_value="openrouter",
), patch(
"agent.auxiliary_client._read_main_model",
return_value="anthropic/claude-opus-4.6",
), patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("nous", None, None, None, None), # explicit override
), patch(
"agent.auxiliary_client._resolve_strict_vision_backend"
) as mock_strict:
mock_strict.return_value = (MagicMock(), "nous-default-model")
from agent.auxiliary_client import resolve_vision_provider_client
provider, client, model = resolve_vision_provider_client()
# Explicit "nous" override → uses strict backend, NOT main model path
assert provider == "nous"
mock_strict.assert_called_once_with("nous", None)
# ── Vision — custom provider endpoint credential passthrough ────────────────

View file

@ -25,13 +25,6 @@ def _write_config(tmp_path, config_dict):
class TestNormalizeVisionProvider:
"""_normalize_vision_provider should resolve 'main' to actual main provider."""
def test_main_resolves_to_named_custom(self, tmp_path):
_write_config(tmp_path, {
"model": {"default": "my-model", "provider": "custom:beans"},
"custom_providers": [{"name": "beans", "base_url": "http://localhost/v1"}],
})
from agent.auxiliary_client import _normalize_vision_provider
assert _normalize_vision_provider("main") == "custom:beans"
def test_main_resolves_to_openrouter(self, tmp_path):
_write_config(tmp_path, {
@ -40,30 +33,10 @@ class TestNormalizeVisionProvider:
from agent.auxiliary_client import _normalize_vision_provider
assert _normalize_vision_provider("main") == "openrouter"
def test_main_resolves_to_deepseek(self, tmp_path):
_write_config(tmp_path, {
"model": {"default": "deepseek-chat", "provider": "deepseek"},
})
from agent.auxiliary_client import _normalize_vision_provider
assert _normalize_vision_provider("main") == "deepseek"
def test_main_falls_back_to_custom_when_no_provider(self, tmp_path):
_write_config(tmp_path, {"model": {"default": "gpt-4o"}})
from agent.auxiliary_client import _normalize_vision_provider
assert _normalize_vision_provider("main") == "custom"
def test_bare_provider_name_unchanged(self):
from agent.auxiliary_client import _normalize_vision_provider
assert _normalize_vision_provider("beans") == "beans"
assert _normalize_vision_provider("deepseek") == "deepseek"
def test_custom_colon_named_provider_preserved(self):
from agent.auxiliary_client import _normalize_vision_provider
assert _normalize_vision_provider("custom:beans") == "beans"
def test_codex_alias_still_works(self):
from agent.auxiliary_client import _normalize_vision_provider
assert _normalize_vision_provider("codex") == "openai-codex"
def test_auto_unchanged(self):
from agent.auxiliary_client import _normalize_vision_provider
@ -136,18 +109,6 @@ class TestResolveProviderClientNamedCustom:
assert model == "my-model"
assert "beans.local" in str(client.base_url)
def test_named_custom_provider_default_model(self, tmp_path):
_write_config(tmp_path, {
"model": {"default": "main-model"},
"custom_providers": [
{"name": "beans", "base_url": "http://beans.local/v1", "api_key": "k"},
],
})
from agent.auxiliary_client import resolve_provider_client
client, model = resolve_provider_client("beans")
assert client is not None
# Should use _read_main_model() fallback
assert model == "main-model"
def test_named_custom_no_api_key_uses_fallback(self, tmp_path):
_write_config(tmp_path, {
@ -161,17 +122,6 @@ class TestResolveProviderClientNamedCustom:
assert client is not None
# no-key-required should be used
def test_nonexistent_named_custom_falls_through(self, tmp_path):
_write_config(tmp_path, {
"model": {"default": "test"},
"custom_providers": [
{"name": "beans", "base_url": "http://beans.local/v1"},
],
})
from agent.auxiliary_client import resolve_provider_client
# "coffee" doesn't exist in custom_providers
client, model = resolve_provider_client("coffee", "test")
assert client is None
class TestResolveProviderClientModelNormalization:
@ -196,24 +146,6 @@ class TestResolveProviderClientModelNormalization:
assert client is not None
assert model == "glm-5.1"
def test_non_matching_prefix_is_preserved_for_direct_provider(self, tmp_path):
_write_config(tmp_path, {
"model": {"default": "zai/glm-5.1", "provider": "zai"},
})
with (
patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
"api_key": "glm-key",
"base_url": "https://api.z.ai/api/paas/v4",
}),
patch("agent.auxiliary_client.OpenAI") as mock_openai,
):
mock_openai.return_value = MagicMock()
from agent.auxiliary_client import resolve_provider_client
client, model = resolve_provider_client("zai", "google/gemini-2.5-pro")
assert client is not None
assert model == "google/gemini-2.5-pro"
def test_aggregator_vendor_slug_is_preserved(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
@ -306,37 +238,7 @@ class TestProvidersDictApiModeAnthropicMessages:
assert entry.get("base_url") == "https://example-relay.test/anthropic"
assert entry.get("api_key") == "sk-test"
def test_providers_dict_invalid_api_mode_is_dropped(self, tmp_path):
_write_config(tmp_path, {
"providers": {
"weird": {
"name": "weird",
"base_url": "https://example.test",
"api_mode": "bogus_nonsense",
"default_model": "x",
},
},
})
from hermes_cli.runtime_provider import _get_named_custom_provider
entry = _get_named_custom_provider("weird")
assert entry is not None
assert "api_mode" not in entry
def test_providers_dict_without_api_mode_is_unchanged(self, tmp_path):
_write_config(tmp_path, {
"providers": {
"localchat": {
"name": "localchat",
"base_url": "http://127.0.0.1:1234/v1",
"api_key": "local-key",
"default_model": "llama-3",
},
},
})
from hermes_cli.runtime_provider import _get_named_custom_provider
entry = _get_named_custom_provider("localchat")
assert entry is not None
assert "api_mode" not in entry
def test_resolve_provider_client_returns_anthropic_client(self, tmp_path, monkeypatch):
"""Named custom provider with api_mode=anthropic_messages must
@ -370,62 +272,7 @@ class TestProvidersDictApiModeAnthropicMessages:
)
assert async_model == "claude-opus-4-7"
def test_aux_task_override_routes_named_provider_to_anthropic(self, tmp_path, monkeypatch):
"""The full chain: auxiliary.<task>.provider: myrelay with
api_mode anthropic_messages must produce an Anthropic client."""
monkeypatch.setenv("MYRELAY_API_KEY", "sk-test")
_write_config(tmp_path, {
"providers": {
"myrelay": {
"name": "myrelay",
"base_url": "https://example-relay.test/anthropic",
"key_env": "MYRELAY_API_KEY",
"api_mode": "anthropic_messages",
"default_model": "claude-opus-4-7",
},
},
"auxiliary": {
"compression": {
"provider": "myrelay",
"model": "claude-sonnet-4.6",
},
},
"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"},
})
from agent.auxiliary_client import (
get_async_text_auxiliary_client,
get_text_auxiliary_client,
AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient,
)
async_client, async_model = get_async_text_auxiliary_client("compression")
assert isinstance(async_client, AsyncAnthropicAuxiliaryClient)
assert async_model == "claude-sonnet-4.6"
sync_client, sync_model = get_text_auxiliary_client("compression")
assert isinstance(sync_client, AnthropicAuxiliaryClient)
assert sync_model == "claude-sonnet-4.6"
def test_provider_without_api_mode_still_uses_openai(self, tmp_path):
"""Named providers that don't declare api_mode should still go
through the plain OpenAI-wire path (no regression)."""
_write_config(tmp_path, {
"providers": {
"localchat": {
"name": "localchat",
"base_url": "http://127.0.0.1:1234/v1",
"api_key": "local-key",
"default_model": "llama-3",
},
},
})
from agent.auxiliary_client import resolve_provider_client
from openai import OpenAI, AsyncOpenAI
sync_client, _ = resolve_provider_client("localchat", async_mode=False)
# sync returns the raw OpenAI client
assert isinstance(sync_client, OpenAI)
async_client, _ = resolve_provider_client("localchat", async_mode=True)
assert isinstance(async_client, AsyncOpenAI)
class TestCustomProviderAliasCollision:

View file

@ -154,141 +154,10 @@ async def test_async_auxiliary_attempt_uses_inherited_relay_adapter(monkeypatch)
]
def test_terminal_auxiliary_failure_stays_failed_when_caller_catches_it(
relay_turn, monkeypatch
):
_relay, turn = relay_turn
consumer = "test.terminal-auxiliary-failure"
turn.lease.host.retain_managed_execution(consumer)
outcomes = []
original_pop = turn.lease.host.relay.scope.pop
def record_pop(*args, **kwargs):
outcomes.append((kwargs.get("output") or {}).get("outcome"))
return original_pop(*args, **kwargs)
monkeypatch.setattr(turn.lease.host.relay.scope, "pop", record_pop)
client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_kwargs: SimpleNamespace(choices=[]),
)
)
)
@auxiliary_client._relay_auxiliary_call
def run(task):
auxiliary_client._set_relay_auxiliary_route(
"openrouter",
"test-model",
"chat_completions",
)
with pytest.raises(RuntimeError, match="invalid response"):
auxiliary_client._validate_llm_response(
auxiliary_client._relay_sync_completion(
client,
{"model": "test-model", "messages": []},
),
task,
)
assert len(turn.logical_llm_calls) == 1
return auxiliary_client._validate_llm_response(
auxiliary_client._relay_sync_completion(
client,
{"model": "test-model", "messages": []},
),
task,
)
try:
with pytest.raises(RuntimeError, match="invalid response"):
run("compression")
assert outcomes == ["failed"]
assert turn.logical_llm_calls == {}
relay_runtime.SESSION_COORDINATOR.end_turn(turn, outcome="success")
assert outcomes == ["failed", "success"]
finally:
turn.lease.host.release_managed_execution(consumer)
@pytest.mark.asyncio
async def test_async_terminal_auxiliary_failure_closes_logical_call(relay_turn):
_relay, turn = relay_turn
consumer = "test.async-terminal-auxiliary-failure"
turn.lease.host.retain_managed_execution(consumer)
async def create(**_kwargs):
return SimpleNamespace(choices=[])
client = SimpleNamespace(
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
)
@auxiliary_client._relay_auxiliary_call_async
async def run(task):
auxiliary_client._set_relay_auxiliary_route(
"anthropic",
"claude-test",
"chat_completions",
)
with pytest.raises(RuntimeError, match="invalid response"):
auxiliary_client._validate_llm_response(
await auxiliary_client._relay_async_completion(
client,
{"model": "claude-test", "messages": []},
),
task,
)
assert len(turn.logical_llm_calls) == 1
return auxiliary_client._validate_llm_response(
await auxiliary_client._relay_async_completion(
client,
{"model": "claude-test", "messages": []},
),
task,
)
try:
with pytest.raises(RuntimeError, match="invalid response"):
await run("title_generation")
assert turn.logical_llm_calls == {}
finally:
turn.lease.host.release_managed_execution(consumer)
def test_auxiliary_stream_uses_streaming_relay_primitive(monkeypatch):
captured = {}
raw_stream = iter([{"delta": "one"}, {"delta": "two"}])
client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(create=lambda **_kwargs: raw_stream)
)
)
def stream_current(request, stream_factory, **kwargs):
captured.update(kwargs)
return stream_factory(request)
monkeypatch.setattr(relay_llm, "stream_current", stream_current)
@auxiliary_client._relay_auxiliary_call
def run(task):
auxiliary_client._set_relay_auxiliary_route(
"openrouter",
"moa-model",
"chat_completions",
)
return auxiliary_client._relay_sync_stream(
client,
{"model": "moa-model", "messages": [], "stream": True},
)
assert list(run("moa")) == [{"delta": "one"}, {"delta": "two"}]
assert captured["metadata"]["call_role"] == "auxiliary:moa"
def test_partial_auxiliary_stream_failure_closes_before_recovery(
@ -391,55 +260,3 @@ def test_partial_auxiliary_stream_failure_closes_before_recovery(
turn.lease.host.release_managed_execution(consumer)
def test_auxiliary_attempt_uses_real_relay_request_intercepts(relay_turn):
relay, turn = relay_turn
consumer = "test.auxiliary-request-intercept"
turn.lease.host.retain_managed_execution(consumer)
captured_requests = []
client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **kwargs: captured_requests.append(kwargs)
or SimpleNamespace(
choices=[
SimpleNamespace(message=SimpleNamespace(content="ok"))
]
),
)
)
)
def rewrite_request(_name, request, annotated):
annotated.params = {**(annotated.params or {}), "temperature": 0.25}
return relay.LLMRequestInterceptOutcome(request, annotated)
relay.intercepts.register_llm_request(
"hermes-auxiliary-request",
1,
False,
rewrite_request,
)
try:
@auxiliary_client._relay_auxiliary_call
def run(task):
auxiliary_client._set_relay_auxiliary_route(
"openrouter",
"test-model",
"chat_completions",
)
return auxiliary_client._validate_llm_response(
auxiliary_client._relay_sync_completion(
client,
{"model": "test-model", "messages": []},
),
task,
)
result = run("compression")
finally:
relay.intercepts.deregister_llm_request("hermes-auxiliary-request")
turn.lease.host.release_managed_execution(consumer)
assert result.choices[0].message.content == "ok"
assert captured_requests[0]["temperature"] == 0.25
assert turn.logical_llm_calls == {}

View file

@ -36,29 +36,6 @@ def _runtime(model: str, *, provider: str = "custom:llama-swap") -> dict:
}
def test_implicit_auto_cache_rebuilds_after_runtime_model_switch():
"""A /model switch must not reuse the old implicit-auto cache entry."""
built = []
def fake_resolve(_provider, _model, _async_mode, *, main_runtime, **_kwargs):
client = MagicMock(name=f"client-{main_runtime['model']}")
built.append((client, dict(main_runtime)))
return client, main_runtime["model"]
with patch.object(aux, "resolve_provider_client", side_effect=fake_resolve):
aux.set_runtime_main(**_runtime("qwen35b-code"))
first_client, first_model = aux._get_cached_client("auto")
aux.set_runtime_main(**_runtime("qwen27b-code"))
second_client, second_model = aux._get_cached_client("auto")
assert first_model == "qwen35b-code"
assert second_model == "qwen27b-code"
assert second_client is not first_client
assert [runtime["model"] for _, runtime in built] == [
"qwen35b-code",
"qwen27b-code",
]
def test_implicit_runtime_cache_key_covers_full_connection_and_auth_surface():
@ -83,37 +60,8 @@ def test_implicit_runtime_cache_key_covers_full_connection_and_auth_surface():
assert len(set(keys)) == len(keys)
def test_implicit_runtime_is_isolated_between_concurrent_session_contexts():
"""Concurrent gateway sessions must not read each other's live runtime."""
barrier = Barrier(2)
def session(model: str):
aux.set_runtime_main(**_runtime(model))
barrier.wait()
normalized = aux._normalize_main_runtime(None)
return normalized["model"], aux._client_cache_key("auto", async_mode=False)
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(session, "session-a-model")
second = pool.submit(session, "session-b-model")
model_a, key_a = first.result()
model_b, key_b = second.result()
assert model_a == "session-a-model"
assert model_b == "session-b-model"
assert key_a != key_b
def test_context_without_runtime_does_not_fall_back_to_other_session_globals():
"""A fresh context must not inherit another session's compatibility mirrors."""
aux.set_runtime_main(**_runtime("other-session-model"))
def fresh_context():
return aux._normalize_main_runtime(None)
import contextvars
assert contextvars.Context().run(fresh_context) == {}
def test_runtime_context_token_restores_previous_value_after_turn():
@ -126,74 +74,10 @@ def test_runtime_context_token_restores_previous_value_after_turn():
assert aux._normalize_main_runtime(None) == {}
def test_aiagent_wrapper_resets_runtime_context_after_turn():
"""Every production run_conversation exit restores the caller's Context."""
from run_agent import AIAgent
agent = SimpleNamespace(
_conversation_root_id=lambda: "root-session",
_session_db=None,
session_id="session-id",
)
def fake_turn(*_args, **_kwargs):
aux.set_runtime_main(**_runtime("wrapped-turn"))
return {"final_response": "ok"}
with patch("agent.conversation_loop.run_conversation", side_effect=fake_turn):
result = AIAgent.run_conversation(agent, "hello")
assert result["final_response"] == "ok"
assert aux._normalize_main_runtime(None) == {}
def test_legacy_patched_globals_are_visible_only_without_an_active_runtime():
"""Direct legacy patches work, but never override context-local session state."""
with patch.object(aux, "_RUNTIME_MAIN_PROVIDER", "custom:legacy"), patch.object(
aux, "_RUNTIME_MAIN_MODEL", "legacy-model"
), patch.object(
aux, "_RUNTIME_MAIN_BASE_URL", "https://legacy.test/v1"
):
assert aux._normalize_main_runtime(None)["model"] == "legacy-model"
aux.set_runtime_main(**_runtime("active-session-model"))
runtime = aux._normalize_main_runtime(None)
assert runtime["model"] == "active-session-model"
assert runtime["base_url"] == "http://llama-swap.test/v1"
def test_concurrent_vision_probes_use_each_sessions_endpoint_and_model():
"""Vision auto-routing must not mix custom endpoints across sessions."""
barrier = Barrier(2)
def fake_resolve(provider, model, **kwargs):
barrier.wait()
client = MagicMock()
client.probed_base_url = kwargs.get("explicit_base_url")
return client, model
def probe(model: str, base_url: str):
runtime = _runtime(model)
runtime["base_url"] = base_url
aux.set_runtime_main(**runtime)
provider, client, resolved_model = aux.resolve_vision_provider_client()
assert client is not None
return provider, resolved_model, client.probed_base_url
with patch.object(
aux, "_resolve_task_provider_model", return_value=("auto", None, None, None, None)
), patch.object(aux, "_main_model_supports_vision", return_value=True), patch.object(
aux, "resolve_provider_client", side_effect=fake_resolve
):
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(probe, "vision-a", "https://a.test/v1")
second = pool.submit(probe, "vision-b", "https://b.test/v1")
result_a = first.result()
result_b = second.result()
assert result_a == ("custom:llama-swap", "vision-a", "https://a.test/v1")
assert result_b == ("custom:llama-swap", "vision-b", "https://b.test/v1")
def test_explicit_model_cache_isolation_remains_independent_of_runtime_key():
@ -208,86 +92,12 @@ def test_explicit_model_cache_isolation_remains_independent_of_runtime_key():
assert first != second
def test_pinned_provider_without_model_inherits_live_runtime_model_in_cache_key():
"""A pinned provider with model=auto must follow the switched main model."""
first = aux._client_cache_key(
"openrouter",
async_mode=False,
main_runtime=_runtime("old-model", provider="openrouter"),
)
second = aux._client_cache_key(
"openrouter",
async_mode=False,
main_runtime=_runtime("new-model", provider="openrouter"),
)
assert first != second
def test_explicit_vision_runtime_wins_over_stale_ambient_runtime():
"""Vision resolution must use the immutable runtime supplied by its caller."""
aux.set_runtime_main(**_runtime("ambient-old"))
explicit = _runtime("explicit-new")
captured = {}
def fake_resolve(provider, model, **kwargs):
captured.update(provider=provider, model=model, **kwargs)
return MagicMock(), model
with patch.object(
aux, "_resolve_task_provider_model", return_value=("auto", None, None, None, None)
), patch.object(aux, "_main_model_supports_vision", return_value=True), patch.object(
aux, "resolve_provider_client", side_effect=fake_resolve
):
provider, _client, model = aux.resolve_vision_provider_client(
main_runtime=explicit
)
assert provider == "custom:llama-swap"
assert model == "explicit-new"
assert captured["explicit_base_url"] == "http://llama-swap.test/v1"
def test_image_routing_does_not_borrow_base_url_from_different_provider():
"""An explicit provider must not inherit another runtime's custom endpoint."""
from agent.image_routing import _resolve_inference_base_url
aux.set_runtime_main(**_runtime("custom-model"))
cfg = {
"model": {
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
}
}
assert (
_resolve_inference_base_url(cfg, "openrouter")
== "https://openrouter.ai/api/v1"
)
def test_async_initial_cache_lookup_receives_explicit_runtime_snapshot():
"""The first async lookup must not drop main_runtime and only pass it on fallback."""
runtime = _runtime("async-new")
response = MagicMock()
response.choices = [MagicMock(message=MagicMock(content="ok"))]
client = MagicMock()
client.chat.completions.create = AsyncMock(return_value=response)
with patch.object(
aux,
"_resolve_task_provider_model",
return_value=("openrouter", None, None, None, None),
), patch.object(aux, "_get_cached_client", return_value=(client, "async-new")) as get_client:
asyncio.run(
aux.async_call_llm(
task="approval",
main_runtime=runtime,
messages=[{"role": "user", "content": "approve?"}],
)
)
assert get_client.call_args.kwargs["main_runtime"] == aux._normalize_main_runtime(runtime)
def test_unhashable_callable_runtime_api_keys_are_safe_secret_free_discriminators():
@ -342,24 +152,3 @@ def test_string_api_keys_are_not_retained_in_cache_key_repr():
assert second_secret not in rendered
def test_fifo_eviction_does_not_close_client_that_may_have_an_inflight_call():
"""A bounded-cache eviction must not invalidate another caller's client."""
clients = []
def fake_resolve(_provider, model, _async_mode, **_kwargs):
client = MagicMock(name=f"client-{model}")
clients.append(client)
return client, model
with patch.object(aux, "resolve_provider_client", side_effect=fake_resolve):
for index in range(65):
aux._get_cached_client("custom", model=f"model-{index}")
assert len(aux._client_cache) == 64
for client in clients:
client.close.assert_not_called()
aux.shutdown_cached_clients()
clients[0].close.assert_not_called()
for client in clients[1:]:
client.close.assert_called_once_with()

View file

@ -22,8 +22,6 @@ from unittest.mock import patch
import pytest
class _ConnErr(Exception):
"""Stand-in that the transient detector recognizes as a connection blip."""
def test_transient_retry_count_default(monkeypatch):
@ -36,17 +34,6 @@ def test_transient_retry_count_default(monkeypatch):
assert ac._transient_retry_count() == ac._DEFAULT_TRANSIENT_RETRIES
def test_transient_retry_count_configurable_and_clamped():
from agent import auxiliary_client as ac
with patch("hermes_cli.config.cfg_get", return_value=4):
assert ac._transient_retry_count() == 4
with patch("hermes_cli.config.cfg_get", return_value=100):
assert ac._transient_retry_count() == 6 # clamped high
with patch("hermes_cli.config.cfg_get", return_value=-3):
assert ac._transient_retry_count() == 0 # clamped low
with patch("hermes_cli.config.cfg_get", side_effect=RuntimeError):
assert ac._transient_retry_count() == ac._DEFAULT_TRANSIENT_RETRIES
def test_model_participates_in_client_cache_key():
@ -73,10 +60,3 @@ def test_model_participates_in_client_cache_key():
assert k_opus == k_opus2
def test_missing_model_key_is_stable():
"""Omitting model (legacy callers) is still a valid, stable key."""
from agent.auxiliary_client import _client_cache_key
a = _client_cache_key("openrouter", async_mode=False, base_url="u", api_key="k")
b = _client_cache_key("openrouter", async_mode=False, base_url="u", api_key="k")
assert a == b

View file

@ -60,117 +60,18 @@ def test_endpoint_speaks_anthropic_messages(url, expected, label):
# _maybe_wrap_anthropic decision table
# ---------------------------------------------------------------------------
def test_maybe_wrap_anthropic_rewraps_kimi_coding_url():
"""Plain OpenAI client pointed at api.kimi.com/coding gets rewrapped."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
plain_client = MagicMock(name="plain_openai")
fake_anthropic = MagicMock(name="anthropic_sdk_client")
with patch(
"agent.anthropic_adapter.build_anthropic_client",
return_value=fake_anthropic,
):
result = _maybe_wrap_anthropic(
plain_client, "kimi-for-coding", "sk-kimi-test",
"https://api.kimi.com/coding", api_mode=None,
)
assert isinstance(result, AnthropicAuxiliaryClient)
def test_maybe_wrap_anthropic_rewraps_slash_anthropic_url():
"""Plain OpenAI client pointed at any /anthropic URL gets rewrapped."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
plain_client = MagicMock(name="plain_openai")
fake_anthropic = MagicMock(name="anthropic_sdk_client")
with patch(
"agent.anthropic_adapter.build_anthropic_client",
return_value=fake_anthropic,
):
result = _maybe_wrap_anthropic(
plain_client, "MiniMax-M2.7", "mm-key",
"https://api.minimax.io/anthropic", api_mode=None,
)
assert isinstance(result, AnthropicAuxiliaryClient)
def test_maybe_wrap_anthropic_skips_openai_wire_urls():
"""OpenRouter / OpenAI / Moonshot-legacy stay as plain OpenAI clients."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
plain_client = MagicMock(name="plain_openai")
# No patch on build_anthropic_client — if the function tried to call it,
# we'd get an AttributeError-style failure. The point is it shouldn't.
result = _maybe_wrap_anthropic(
plain_client, "claude-sonnet-4.6", "sk-or-test",
"https://openrouter.ai/api/v1", api_mode=None,
)
assert result is plain_client
assert not isinstance(result, AnthropicAuxiliaryClient)
def test_maybe_wrap_anthropic_respects_explicit_chat_completions():
"""api_mode=chat_completions overrides URL heuristics."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
plain_client = MagicMock(name="plain_openai")
result = _maybe_wrap_anthropic(
plain_client, "kimi-for-coding", "sk-kimi-test",
"https://api.kimi.com/coding",
api_mode="chat_completions", # explicit override
)
assert result is plain_client, "Explicit chat_completions must bypass wrap"
assert not isinstance(result, AnthropicAuxiliaryClient)
def test_maybe_wrap_anthropic_honors_explicit_anthropic_messages():
"""api_mode=anthropic_messages wraps even when URL wouldn't trigger."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
plain_client = MagicMock(name="plain_openai")
fake_anthropic = MagicMock(name="anthropic_sdk_client")
with patch(
"agent.anthropic_adapter.build_anthropic_client",
return_value=fake_anthropic,
):
result = _maybe_wrap_anthropic(
plain_client, "model-name", "some-key",
"https://opaque.internal/v1", # URL alone wouldn't trigger
api_mode="anthropic_messages",
)
assert isinstance(result, AnthropicAuxiliaryClient)
def test_maybe_wrap_anthropic_double_wrap_safe():
"""Already-wrapped AnthropicAuxiliaryClient passes through unchanged."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
already_wrapped = MagicMock(spec=AnthropicAuxiliaryClient)
result = _maybe_wrap_anthropic(
already_wrapped, "model", "key",
"https://api.kimi.com/coding", api_mode=None,
)
assert result is already_wrapped
def test_maybe_wrap_anthropic_codex_client_passes_through():
"""CodexAuxiliaryClient is never re-dispatched."""
from agent.auxiliary_client import (
_maybe_wrap_anthropic,
CodexAuxiliaryClient,
AnthropicAuxiliaryClient,
)
codex_client = MagicMock(spec=CodexAuxiliaryClient)
result = _maybe_wrap_anthropic(
codex_client, "model", "key",
"https://api.kimi.com/coding", api_mode=None,
)
assert result is codex_client
assert not isinstance(result, AnthropicAuxiliaryClient)
def test_maybe_wrap_anthropic_sdk_missing_falls_back():

View file

@ -40,25 +40,8 @@ class TestApplyUserDefaultHeadersHelper:
assert merged["User-Agent"] == "curl/8.7.1" # user wins
assert merged["X-Extra"] == "1"
def test_no_config_is_noop_returns_original(self, tmp_path):
_write_config(tmp_path, {"model": {"default": "m"}})
from agent.auxiliary_client import _apply_user_default_headers
original = {"User-Agent": "OpenAI/Python"}
merged = _apply_user_default_headers(original)
assert merged == original
def test_none_headers_with_config_creates_dict(self, tmp_path):
_write_config(tmp_path, {
"model": {"default": "m", "default_headers": {"User-Agent": "curl/8.7.1"}},
})
from agent.auxiliary_client import _apply_user_default_headers
merged = _apply_user_default_headers(None)
assert merged == {"User-Agent": "curl/8.7.1"}
def test_none_headers_no_config_returns_none(self, tmp_path):
_write_config(tmp_path, {"model": {"default": "m"}})
from agent.auxiliary_client import _apply_user_default_headers
assert _apply_user_default_headers(None) is None
def test_none_values_skipped(self, tmp_path):
_write_config(tmp_path, {

View file

@ -85,14 +85,7 @@ class TestMaterializeBearerForHttp:
assert materialize_bearer_for_http(provider) == "fresh-jwt"
assert invoked["count"] == 1
def test_string_passes_through(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
assert materialize_bearer_for_http("plain-key") == "plain-key"
def test_callable_returning_empty_raises(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
with pytest.raises(ValueError):
materialize_bearer_for_http(lambda: "")
def test_empty_string_raises(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
@ -113,17 +106,6 @@ class TestBuildBearerHttpClient:
how Entra ID auth reaches the Anthropic SDK (which does not accept
callable ``auth_token``)."""
def test_returns_httpx_client_with_request_hook(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
client = build_bearer_http_client(lambda: "jwt")
try:
assert isinstance(client, httpx.Client)
hooks = client.event_hooks.get("request", [])
assert len(hooks) >= 1
finally:
client.close()
def test_hook_overrides_authorization_header(self):
import httpx
@ -207,25 +189,7 @@ class TestBuildBearerHttpClient:
finally:
client.close()
def test_rejects_non_callable_provider(self):
from agent.azure_identity_adapter import build_bearer_http_client
with pytest.raises(ValueError):
build_bearer_http_client(cast(Callable[[], str], "plain-string-not-callable"))
with pytest.raises(ValueError):
build_bearer_http_client(cast(Callable[[], str], None))
def test_forwards_httpx_kwargs(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
timeout = httpx.Timeout(60.0, connect=5.0)
client = build_bearer_http_client(lambda: "jwt", timeout=timeout)
try:
# httpx stores the timeout per-pool; just sanity-check it was
# accepted without TypeError.
assert client is not None
finally:
client.close()
class TestIsTokenProvider:
@ -259,42 +223,9 @@ class TestEntraIdentityConfig:
rebuilt = EntraIdentityConfig.from_dict(cfg.to_dict())
assert rebuilt == cfg
def test_from_dict_handles_empty_strings(self):
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict({
"scope": "",
"client_id": None,
})
# Empty scope falls back to default
assert cfg.scope.endswith("/.default")
def test_from_dict_ignores_legacy_identity_keys(self):
"""Old config.yaml that still has model.entra.client_id /
tenant_id / authority should not crash from_dict those values
are now read from AZURE_* env vars by azure-identity directly."""
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict({
"tenant_id": "legacy-tenant",
"authority": "https://login.partner.microsoftonline.cn",
"client_id": "user-mi-client",
})
# Legacy keys silently ignored — no crash, no surprise field on the dataclass.
assert not hasattr(cfg, "client_id")
assert not hasattr(cfg, "tenant_id")
assert not hasattr(cfg, "authority")
def test_constructor_normalizes_empty_scope(self):
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig(scope="")
assert cfg.scope.endswith("/.default")
def test_from_dict_default_scope_override(self):
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict(
{"scope": ""},
default_scope="https://custom.example/.default",
)
assert cfg.scope == "https://custom.example/.default"
def test_dataclass_is_frozen(self):
# Frozen dataclasses are hashable / safe to pass through caches.
@ -372,15 +303,6 @@ class TestBuildCredential:
assert kwargs == {}
assert cred is not None
def test_interactive_browser_opt_in(self, fake_azure_identity):
"""When the user explicitly sets
``exclude_interactive_browser=False``, the SDK kwarg is set to
False. Without the opt-in we don't pass the kwarg at all (SDK
default is True / browser excluded)."""
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
build_credential(EntraIdentityConfig(exclude_interactive_browser=False))
kwargs = fake_azure_identity.last_credential_kwargs
assert kwargs["exclude_interactive_browser_credential"] is False
def test_credential_is_cached_per_config(self, fake_azure_identity):
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
@ -397,17 +319,6 @@ class TestBuildCredential:
assert c1 is not c2
assert fake_azure_identity.credential_count == 2
def test_reset_cache_invalidates(self, fake_azure_identity):
from agent.azure_identity_adapter import (
EntraIdentityConfig,
build_credential,
reset_credential_cache,
)
cfg = EntraIdentityConfig(scope="x")
c1 = build_credential(cfg)
reset_credential_cache()
c2 = build_credential(cfg)
assert c1 is not c2
class TestBuildTokenProvider:
@ -418,25 +329,7 @@ class TestBuildTokenProvider:
assert provider() == "jwt-for-https://ai.azure.com/.default"
assert fake_azure_identity.last_scope == "https://ai.azure.com/.default"
def test_falls_back_to_default_scope_when_unspecified(self, fake_azure_identity):
"""When neither ``scope`` nor ``config`` is provided,
``build_token_provider`` uses ``SCOPE_AI_AZURE_DEFAULT``
Microsoft's documented Foundry inference scope. ``base_url`` is
accepted for back-compat but ignored."""
from agent.azure_identity_adapter import (
SCOPE_AI_AZURE_DEFAULT,
build_token_provider,
)
build_token_provider(base_url="https://r.openai.azure.com/openai/v1")
assert fake_azure_identity.last_scope == SCOPE_AI_AZURE_DEFAULT
def test_explicit_scope_wins_over_base_url(self, fake_azure_identity):
from agent.azure_identity_adapter import build_token_provider
build_token_provider(
scope="https://override.example/.default",
base_url="https://r.openai.azure.com/openai/v1",
)
assert fake_azure_identity.last_scope == "https://override.example/.default"
def test_config_object_wins_over_kwargs(self, fake_azure_identity):
from agent.azure_identity_adapter import (
@ -497,12 +390,6 @@ class TestRequireAzureIdentityMissing:
class TestHasAzureIdentityCredentials:
def test_returns_false_when_package_missing_and_install_disabled(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
assert _adapter.has_azure_identity_credentials(
"https://x/.default", allow_install=False,
) is False
def test_lazy_install_triggered_when_package_missing(self, monkeypatch):
"""With allow_install=True (default), the probe must trigger the
@ -545,22 +432,7 @@ class TestHasAzureIdentityCredentials:
)
assert result is True
def test_returns_true_on_successful_token_mint(self, fake_azure_identity):
from agent.azure_identity_adapter import has_azure_identity_credentials
assert has_azure_identity_credentials("https://x/.default", timeout_seconds=0.5) is True
def test_returns_false_when_get_token_raises(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
def _failing_credential(_config):
class _Cred:
def get_token(self, scope):
raise RuntimeError("simulated chain exhaustion")
return _Cred()
monkeypatch.setattr(_adapter, "build_credential", _failing_credential)
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True)
assert _adapter.has_azure_identity_credentials("https://x/.default", timeout_seconds=0.5) is False
def test_returns_false_on_timeout(self, monkeypatch):
"""Slow IMDS / network must time out, not hang the caller."""
@ -594,15 +466,6 @@ class TestHasAzureIdentityCredentials:
class TestDescribeActiveCredential:
def test_reports_not_installed(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
info = _adapter.describe_active_credential(
scope="https://x/.default", allow_install=False,
)
assert info["ok"] is False
assert "not installed" in info["error"].lower()
assert "pip install" in info["hint"].lower()
def test_reports_install_failure(self, monkeypatch):
"""When lazy install is allowed but fails (e.g. lazy installs
@ -629,33 +492,5 @@ class TestDescribeActiveCredential:
sources = info.get("env_sources") or []
assert any("ManagedIdentity" in s for s in sources)
def test_reports_env_sources_for_workload_identity(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", "/var/secrets/azure/federated-token")
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
sources = info.get("env_sources") or []
assert any("WorkloadIdentity" in s for s in sources)
def test_reports_env_sources_for_service_principal(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
monkeypatch.setenv("AZURE_TENANT_ID", "t")
monkeypatch.setenv("AZURE_CLIENT_ID", "c")
monkeypatch.setenv("AZURE_CLIENT_SECRET", "s")
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
sources = info.get("env_sources") or []
assert any("EnvironmentCredential" in s for s in sources)
def test_reports_error_on_chain_failure(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
def _failing_credential(_config):
class _Cred:
def get_token(self, scope):
raise RuntimeError("auth failed")
return _Cred()
monkeypatch.setattr(_adapter, "build_credential", _failing_credential)
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True)
info = _adapter.describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
assert info["ok"] is False
assert "auth failed" in info.get("error", "")

View file

@ -54,11 +54,6 @@ class TestSameDeployment:
assert not same_deployment(sibling, failed)
assert not should_skip_candidate(sibling, failed, FailureScope.MODEL)
def test_exact_same_deployment_is_skipped(self):
failed = _id("custom", "zai-org/glm-5.2")
same = _id("custom", "ZAI-ORG/GLM-5.2") # case-insensitive
assert same_deployment(same, failed)
assert should_skip_candidate(same, failed, FailureScope.MODEL)
def test_incident_62984_same_model_different_explicit_url_is_a_pool(self):
"""Several LM Studio endpoints serving one model = a pool, not dups."""
@ -67,12 +62,6 @@ class TestSameDeployment:
assert not same_deployment(a, b)
assert not should_skip_candidate(a, b, FailureScope.MODEL)
def test_unknown_url_inherits_provider_default_and_dedups(self):
"""An entry without base_url inherits the provider default — it
cannot prove it is a different endpoint (#62984 semantics)."""
a = _id("openrouter", "z-ai/glm-4.7")
b = _id("openrouter", "z-ai/glm-4.7", "https://openrouter.ai/api/v1")
assert same_deployment(a, b)
def test_incident_22548_shim_aliases_same_url_same_model_are_same(self):
"""Two custom_providers aliases at one shim URL with one model."""
@ -114,10 +103,6 @@ class TestSameCredentialSurface:
b = _id("proxy-b", "m", "http://gw:9000/v1")
assert not same_credential_surface(a, b)
def test_missing_provider_falls_back_to_url_signal(self):
a = _id("", "m", "http://gw:9000/v1")
b = _id("proxy-b", "m2", "http://gw:9000/v1")
assert same_credential_surface(a, b)
class TestSameEndpoint:

View file

@ -32,36 +32,12 @@ def _fake_psutil(percent, plugged):
return mod
def test_read_battery_no_psutil(monkeypatch):
# Force the import inside read_battery to fail.
monkeypatch.setitem(sys.modules, "psutil", None)
status = read_battery(use_cache=False)
assert status.available is False
assert status.percent is None
def test_read_battery_no_battery(monkeypatch):
mod = types.ModuleType("psutil")
mod.sensors_battery = lambda: None # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "psutil", mod)
status = read_battery(use_cache=False)
assert status.available is False
def test_read_battery_reads_and_clamps(monkeypatch):
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil(87.6, False))
status = read_battery(use_cache=False)
assert status.available is True
assert status.percent == 88 # rounded
assert status.plugged is False
def test_read_battery_clamps_out_of_range(monkeypatch):
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil(150, True))
status = read_battery(use_cache=False)
assert status.percent == 100
assert status.plugged is True
def test_read_battery_caches(monkeypatch):
@ -99,9 +75,6 @@ def test_battery_category_thresholds(percent, plugged, expected):
assert battery_category(status) == expected
def test_battery_category_unavailable_is_dim():
assert battery_category(BatteryStatus(available=False)) == "dim"
assert battery_category(BatteryStatus(available=True, percent=None)) == "dim"
def test_format_and_glyph():

File diff suppressed because it is too large Load diff

View file

@ -37,14 +37,8 @@ def _iter_text_blocks(msgs):
yield tb["text"]
def test_placeholder_is_non_whitespace():
# The core lesson of #9486: a space is whitespace and is itself rejected.
assert _EMPTY_TEXT_PLACEHOLDER.strip(), "placeholder must be non-whitespace"
@pytest.mark.parametrize("value", ["", " ", "\n\n", "\t", None])
def test_safe_text_blank_inputs_become_non_whitespace(value):
assert _safe_text(value).strip()
def test_safe_text_preserves_real_content():
@ -52,39 +46,8 @@ def test_safe_text_preserves_real_content():
assert _safe_text(" padded ") == " padded " # inner content kept verbatim
def test_no_blank_blocks_reach_bedrock():
"""The exact failing history: blank system/assistant/tool/user turns."""
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "system", "content": [{"type": "text", "text": " "}]},
{"role": "user", "content": "search for foo"},
{"role": "assistant", "content": "",
"tool_calls": [{"id": "tc1",
"function": {"name": "search", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "tc1", "content": ""}, # empty tool output
{"role": "assistant", "content": " \n\n "}, # whitespace-only (compaction)
{"role": "user", "content": [{"type": "text", "text": ""}]},
{"role": "assistant", "content": None},
]
_system, msgs = convert_messages_to_converse(messages)
for text in _iter_text_blocks(msgs):
assert text.strip(), f"blank text block would be rejected by Bedrock: {text!r}"
def test_empty_tool_result_gets_placeholder():
"""A tool that returns no output must not produce a blank toolResult block."""
messages = [
{"role": "user", "content": "run it"},
{"role": "assistant", "content": "",
"tool_calls": [{"id": "t1", "function": {"name": "sh", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "t1", "content": " "},
]
_system, msgs = convert_messages_to_converse(messages)
tool_msg = next(m for m in msgs
if any("toolResult" in b for b in m["content"]))
block = next(b for b in tool_msg["content"] if "toolResult" in b)
text = block["toolResult"]["content"][0]["text"]
assert text.strip()
def test_real_content_is_preserved_alongside_blank_siblings():

View file

@ -21,10 +21,6 @@ class TestProviderRegistry:
from hermes_cli.auth import PROVIDER_REGISTRY
assert "bedrock" in PROVIDER_REGISTRY
def test_bedrock_auth_type_is_aws_sdk(self):
from hermes_cli.auth import PROVIDER_REGISTRY
pconfig = PROVIDER_REGISTRY["bedrock"]
assert pconfig.auth_type == "aws_sdk"
def test_bedrock_has_no_api_key_env_vars(self):
"""Bedrock uses the AWS SDK credential chain, not API keys."""
@ -32,10 +28,6 @@ class TestProviderRegistry:
pconfig = PROVIDER_REGISTRY["bedrock"]
assert pconfig.api_key_env_vars == ()
def test_bedrock_base_url_env_var(self):
from hermes_cli.auth import PROVIDER_REGISTRY
pconfig = PROVIDER_REGISTRY["bedrock"]
assert pconfig.base_url_env_var == "BEDROCK_BASE_URL"
class TestProviderAliases:
@ -45,17 +37,8 @@ class TestProviderAliases:
from hermes_cli.models import _PROVIDER_ALIASES
assert _PROVIDER_ALIASES.get("aws") == "bedrock"
def test_aws_bedrock_alias(self):
from hermes_cli.models import _PROVIDER_ALIASES
assert _PROVIDER_ALIASES.get("aws-bedrock") == "bedrock"
def test_amazon_bedrock_alias(self):
from hermes_cli.models import _PROVIDER_ALIASES
assert _PROVIDER_ALIASES.get("amazon-bedrock") == "bedrock"
def test_amazon_alias(self):
from hermes_cli.models import _PROVIDER_ALIASES
assert _PROVIDER_ALIASES.get("amazon") == "bedrock"
class TestProviderLabels:
@ -102,10 +85,6 @@ class TestResolveProvider:
result = resolve_provider("aws")
assert result == "bedrock"
def test_amazon_bedrock_alias_resolves(self):
from hermes_cli.auth import resolve_provider
result = resolve_provider("amazon-bedrock")
assert result == "bedrock"
def test_auto_detect_with_aws_credentials(self, monkeypatch):
"""When AWS credentials are present and no other provider is configured,
@ -130,36 +109,7 @@ class TestResolveProvider:
class TestRuntimeProvider:
"""Verify resolve_runtime_provider() handles bedrock correctly."""
def test_bedrock_runtime_resolution(self, monkeypatch):
from hermes_cli.runtime_provider import resolve_runtime_provider
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
monkeypatch.setenv("AWS_REGION", "eu-west-1")
# Mock resolve_provider to return bedrock
with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \
patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}):
result = resolve_runtime_provider(requested="bedrock")
assert result["provider"] == "bedrock"
assert result["api_mode"] == "bedrock_converse"
assert result["region"] == "eu-west-1"
assert "bedrock-runtime.eu-west-1.amazonaws.com" in result["base_url"]
assert result["api_key"] == "aws-sdk"
def test_bedrock_runtime_default_region(self, monkeypatch):
from hermes_cli.runtime_provider import resolve_runtime_provider
monkeypatch.setenv("AWS_PROFILE", "default")
monkeypatch.delenv("AWS_REGION", raising=False)
monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \
patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}):
result = resolve_runtime_provider(requested="bedrock")
assert result["region"] == "us-east-1"
def test_bedrock_runtime_no_credentials_raises_on_auto_detect(self, monkeypatch):
"""When bedrock is auto-detected (not explicitly requested) and no
@ -215,9 +165,6 @@ class TestProvidersModule:
assert ALIASES.get("aws") == "bedrock"
assert ALIASES.get("aws-bedrock") == "bedrock"
def test_bedrock_transport_mapping(self):
from hermes_cli.providers import TRANSPORT_TO_API_MODE
assert TRANSPORT_TO_API_MODE.get("bedrock_converse") == "bedrock_converse"
def test_determine_api_mode_from_bedrock_url(self):
from hermes_cli.providers import determine_api_mode
@ -225,9 +172,6 @@ class TestProvidersModule:
"unknown", "https://bedrock-runtime.us-east-1.amazonaws.com"
) == "bedrock_converse"
def test_label_override(self):
from hermes_cli.providers import _LABEL_OVERRIDES
assert _LABEL_OVERRIDES.get("bedrock") == "AWS Bedrock"
# ---------------------------------------------------------------------------
@ -305,28 +249,7 @@ class TestBedrockPreserveDotsFlag:
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_bedrock_runtime_us_east_1_url_preserves_dots(self):
"""Defense-in-depth: even without an explicit ``provider="bedrock"``,
a ``bedrock-runtime.us-east-1.amazonaws.com`` base URL must not
mangle dots."""
from types import SimpleNamespace
agent = SimpleNamespace(
provider="custom",
base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
)
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_bedrock_runtime_ap_northeast_2_url_preserves_dots(self):
"""Reporter-reported region (ap-northeast-2) exercises the same
base-URL heuristic."""
from types import SimpleNamespace
agent = SimpleNamespace(
provider="custom",
base_url="https://bedrock-runtime.ap-northeast-2.amazonaws.com",
)
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_non_bedrock_aws_url_does_not_preserve_dots(self):
"""Unrelated AWS endpoints (e.g. ``s3.us-east-1.amazonaws.com``)
@ -341,14 +264,6 @@ class TestBedrockPreserveDotsFlag:
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is False
def test_anthropic_native_still_does_not_preserve_dots(self):
"""Canary: adding Bedrock to the allowlist must not weaken the
existing Anthropic native behaviour ``claude-sonnet-4.6`` still
becomes ``claude-sonnet-4-6`` for the Anthropic API."""
from types import SimpleNamespace
agent = SimpleNamespace(provider="anthropic", base_url="https://api.anthropic.com")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is False
class TestBedrockModelNameNormalization:
@ -363,20 +278,7 @@ class TestBedrockModelNameNormalization:
"global.anthropic.claude-opus-4-7", preserve_dots=True
) == "global.anthropic.claude-opus-4-7"
def test_us_anthropic_dated_inference_profile_preserved(self):
"""Regional + dated Sonnet inference profile."""
from agent.anthropic_adapter import normalize_model_name
assert normalize_model_name(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
preserve_dots=True,
) == "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
def test_apac_anthropic_haiku_inference_profile_preserved(self):
"""APAC inference profile — same structural-dot shape."""
from agent.anthropic_adapter import normalize_model_name
assert normalize_model_name(
"apac.anthropic.claude-haiku-4-5", preserve_dots=True
) == "apac.anthropic.claude-haiku-4-5"
def test_bedrock_prefix_preserved_without_preserve_dots(self):
"""Bedrock inference profile IDs are auto-detected by prefix and
@ -388,16 +290,6 @@ class TestBedrockModelNameNormalization:
"global.anthropic.claude-opus-4-7", preserve_dots=False
) == "global.anthropic.claude-opus-4-7"
def test_bare_foundation_model_id_preserved(self):
"""Non-inference-profile Bedrock IDs
(e.g. ``anthropic.claude-3-5-sonnet-20241022-v2:0``) use dots as
vendor separators and must also survive intact under
``preserve_dots=True``."""
from agent.anthropic_adapter import normalize_model_name
assert normalize_model_name(
"anthropic.claude-3-5-sonnet-20241022-v2:0",
preserve_dots=True,
) == "anthropic.claude-3-5-sonnet-20241022-v2:0"
class TestBedrockBuildAnthropicKwargsEndToEnd:
@ -448,25 +340,10 @@ class TestBedrockModelIdDetection:
from agent.anthropic_adapter import _is_bedrock_model_id
assert _is_bedrock_model_id("anthropic.claude-opus-4-7") is True
def test_regional_us_prefix_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
assert _is_bedrock_model_id("us.anthropic.claude-sonnet-4-5-v1:0") is True
def test_regional_global_prefix_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
assert _is_bedrock_model_id("global.anthropic.claude-opus-4-7") is True
def test_regional_eu_prefix_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
assert _is_bedrock_model_id("eu.anthropic.claude-sonnet-4-6") is True
def test_openrouter_format_not_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
assert _is_bedrock_model_id("claude-opus-4.6") is False
def test_bare_claude_not_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
assert _is_bedrock_model_id("claude-opus-4-7") is False
def test_bare_bedrock_id_preserved_without_flag(self):
"""The primary bug from #12295: ``anthropic.claude-opus-4-7``
@ -477,24 +354,7 @@ class TestBedrockModelIdDetection:
"anthropic.claude-opus-4-7", preserve_dots=False
) == "anthropic.claude-opus-4-7"
def test_openrouter_dots_still_converted(self):
"""Non-Bedrock dotted model names must still be converted."""
from agent.anthropic_adapter import normalize_model_name
assert normalize_model_name("claude-opus-4.6") == "claude-opus-4-6"
def test_bare_bedrock_id_survives_build_kwargs(self):
"""End-to-end: bare Bedrock ID through ``build_anthropic_kwargs``
without ``preserve_dots=True`` -- the auxiliary client path."""
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="anthropic.claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
tools=None,
max_tokens=1024,
reasoning_config=None,
preserve_dots=False,
)
assert kwargs["model"] == "anthropic.claude-opus-4-7"
# ---------------------------------------------------------------------------
@ -538,46 +398,8 @@ class TestAuxiliaryClientBedrockResolution:
assert client is None
assert model is None
def test_bedrock_uses_configured_region(self, monkeypatch):
"""Bedrock client base_url should reflect AWS_REGION."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
monkeypatch.setenv("AWS_REGION", "eu-central-1")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client
client, _ = resolve_provider_client("bedrock", None)
assert client is not None
assert "eu-central-1" in client.base_url
def test_bedrock_respects_explicit_model(self, monkeypatch):
"""When caller passes an explicit model, it should be used."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client
_, model = resolve_provider_client(
"bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
)
assert "claude-sonnet" in model
def test_bedrock_async_mode(self, monkeypatch):
"""Async mode should return an AsyncAnthropicAuxiliaryClient."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client, AsyncAnthropicAuxiliaryClient
client, model = resolve_provider_client("bedrock", None, async_mode=True)
assert client is not None
assert isinstance(client, AsyncAnthropicAuxiliaryClient)
def test_bedrock_default_model_is_haiku(self, monkeypatch):
"""Default auxiliary model for Bedrock should be Haiku (fast, cheap)."""
@ -591,74 +413,9 @@ class TestAuxiliaryClientBedrockResolution:
assert "haiku" in model.lower()
def test_bedrock_non_claude_model_uses_converse_client(self, monkeypatch):
"""Non-Claude Bedrock models (e.g. gpt-oss) must use Converse, not Anthropic SDK."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client") as mock_build:
from agent.auxiliary_client import (
BedrockAuxiliaryClient,
resolve_provider_client,
)
client, model = resolve_provider_client(
"bedrock", "openai.gpt-oss-20b-1:0"
)
mock_build.assert_not_called()
assert isinstance(client, BedrockAuxiliaryClient)
assert model == "openai.gpt-oss-20b-1:0"
def test_bedrock_claude_model_still_uses_anthropic_client(self, monkeypatch):
"""Claude Bedrock IDs should keep the Anthropic SDK auxiliary path."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
mock_anthropic_bedrock = MagicMock()
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
return_value=mock_anthropic_bedrock):
from agent.auxiliary_client import (
AnthropicAuxiliaryClient,
resolve_provider_client,
)
client, model = resolve_provider_client(
"bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
)
assert isinstance(client, AnthropicAuxiliaryClient)
assert "claude-sonnet" in model
def test_bedrock_non_claude_async_mode(self, monkeypatch):
"""Async mode for non-Claude Bedrock should return AsyncBedrockAuxiliaryClient."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client"):
from agent.auxiliary_client import (
AsyncBedrockAuxiliaryClient,
resolve_provider_client,
)
client, _ = resolve_provider_client(
"bedrock", "openai.gpt-oss-20b-1:0", async_mode=True
)
assert isinstance(client, AsyncBedrockAuxiliaryClient)
def test_bedrock_converse_shim_normalizes_string_stop(self, monkeypatch):
"""OpenAI callers may pass stop='STR'; Converse requires a list."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIO...MPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
from agent.auxiliary_client import BedrockAuxiliaryClient
client = BedrockAuxiliaryClient("us-east-1", "openai.gpt-oss-20b-1:0")
with patch("agent.bedrock_adapter.call_converse") as mock_converse:
client.chat.completions.create(
model="openai.gpt-oss-20b-1:0",
messages=[{"role": "user", "content": "hi"}],
stop="STOP",
)
assert mock_converse.call_args.kwargs["stop_sequences"] == ["STOP"]
def test_bedrock_converse_shim_stream_returns_complete_response(self, monkeypatch):
"""stream=True is not supported by the shim — a complete response comes

View file

@ -13,21 +13,8 @@ from agent.billing_links import (
)
def test_nous_route_by_provider_slug():
block = build_billing_block(provider="nous", base_url="", model="hermes-4")
assert block.is_nous is True
assert block.provider_label == "Nous Portal"
# Nous always resolves an in-app/portal billing URL as a fallback.
assert block.billing_url and "nousresearch.com" in block.billing_url
def test_nous_route_by_base_url_host():
block = build_billing_block(
provider="openai_compatible",
base_url="https://inference-api.nousresearch.com/v1",
model="hermes-4",
)
assert block.is_nous is True
def test_is_nous_inference_route_helper():
@ -44,49 +31,12 @@ def test_known_provider_by_slug_resolves_label_and_url():
assert "openai.com" in block.billing_url
def test_openrouter_resolves_credits_page():
block = build_billing_block(
provider="openrouter",
base_url="https://openrouter.ai/api/v1",
model="anthropic/claude",
)
assert block.is_nous is False
assert block.billing_url is not None
assert "openrouter.ai" in block.billing_url
def test_unknown_provider_via_base_url_host_fallback():
# Provider slug is a generic bucket; the host reveals the real upstream.
block = build_billing_block(
provider="custom",
base_url="https://api.deepseek.com/v1",
model="deepseek-chat",
)
assert block.provider_label == "DeepSeek"
assert block.billing_url is not None
assert "deepseek.com" in block.billing_url
def test_unknown_provider_degrades_without_url():
block = build_billing_block(
provider="my_local_llm",
base_url="http://localhost:1234/v1",
model="llama",
)
assert block.is_nous is False
# No invented URL for an unknown provider — but a readable label survives.
assert block.billing_url is None
assert block.provider_label # non-empty, humanized
def test_message_is_carried_through_unchanged():
block = build_billing_block(
provider="openai",
base_url="",
model="gpt-5",
message="You are out of credits.",
)
assert block.message == "You are out of credits."
def test_to_dict_round_trips_all_fields():

View file

@ -49,9 +49,6 @@ class _Boom:
raise RuntimeError("kaboom")
@pytest.mark.parametrize("account", [None, _acct(logged_in=False), _Boom()])
def test_fails_open_to_unavailable(account):
assert usage_model_from_account(account).available is False
@pytest.mark.parametrize(
@ -81,16 +78,8 @@ def test_status_classification(account, expected):
assert m.status == expected
def test_threshold_constant_is_five():
assert LOW_BALANCE_THRESHOLD_USD == 5.0
def test_healthy_carries_plan_name_and_renewal():
m = usage_model_from_account(
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0, current_period_end="2026-07-01"),
paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0))
)
assert m.plan_name == "Plus" and m.renews_at == "2026-07-01"
def test_plan_bar_spent_and_pct():
@ -104,13 +93,6 @@ def test_plan_bar_spent_and_pct():
assert bar.spent_usd == pytest.approx(6.0)
def test_plan_bar_clamps_over_cap_to_zero_spent():
# Rollover/debt: remaining > cap clamps to the cap and reads as zero spent.
m = usage_model_from_account(
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
paid_service_access_info=_Access(subscription_credits_remaining=25.0, total_usable_credits=25.0))
)
assert m.plan_bar.remaining_usd == 20.0 and m.plan_bar.spent_usd == 0.0
def test_topup_bar_is_full_with_no_denominator():
@ -124,22 +106,7 @@ def test_topup_bar_is_full_with_no_denominator():
assert m.total_spendable_usd == 26.0 and m.has_topup is True
def test_no_plan_bar_without_monthly_cap():
m = usage_model_from_account(
_acct(paid_service_access=True, paid_service_access_info=_Access(purchased_credits_remaining=8.0, total_usable_credits=8.0))
)
assert m.plan_bar is None and m.topup_bar is not None
def test_non_finite_values_are_ignored():
m = usage_model_from_account(
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=float("nan")),
paid_service_access_info=_Access(subscription_credits_remaining=float("inf")))
)
assert m.plan_bar is None
def test_usage_bar_fill_fraction_clamped():
assert UsageBar(kind="plan", remaining_usd=30.0, total_usd=20.0).fill_fraction == 1.0
assert UsageBar(kind="plan", remaining_usd=-5.0, total_usd=20.0).fill_fraction == 0.0
assert UsageBar(kind="plan", remaining_usd=0.0, total_usd=0.0).fill_fraction == 0.0

View file

@ -52,43 +52,12 @@ from hermes_cli.nous_billing import (
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"raw,expected",
[
("142.5", Decimal("142.5")), # decimal string, NOT 2dp — the headline case
("100", Decimal("100")),
("10000", Decimal("10000")),
("0.01", Decimal("0.01")),
(250, Decimal("250")),
(" 50 ", Decimal("50")),
],
)
def test_parse_money_valid(raw, expected):
assert parse_money(raw) == expected
@pytest.mark.parametrize("raw", [None, "", "abc", "1.2.3", "$5", {}])
def test_parse_money_invalid_returns_none(raw):
assert parse_money(raw) is None
def test_parse_money_never_uses_binary_float():
# If a float ever sneaks through, we still get an exact decimal, not 0.1+0.2 junk.
assert parse_money(0.1) == Decimal("0.1")
@pytest.mark.parametrize(
"value,expected",
[
(Decimal("142.5"), "$142.50"),
(Decimal("100"), "$100"),
(Decimal("0.01"), "$0.01"),
(Decimal("1000"), "$1000"),
(None, ""),
],
)
def test_format_money(value, expected):
assert format_money(value) == expected
# ---------------------------------------------------------------------------
@ -164,40 +133,10 @@ def test_state_five_roles(
assert state.auto_reload is None
@pytest.mark.parametrize(
"role,server_capability",
[("MEMBER", True), ("OWNER", False)],
)
def test_state_can_change_plan_prefers_server_capability(role, server_capability):
payload = _member_payload()
payload["org"]["role"] = role
payload["canChangePlan"] = server_capability
state = billing_state_from_payload(payload)
assert state.can_change_plan is server_capability
def test_state_can_change_plan_falls_back_to_legacy_role_check():
owner = _member_payload()
owner["org"]["role"] = "OWNER"
member = _member_payload()
assert billing_state_from_payload(owner).can_change_plan is True
assert billing_state_from_payload(member).can_change_plan is False
def test_can_charge_finance_admin_with_server_capability():
"""Server capability can grant FINANCE_ADMIN charge access."""
payload = _member_payload()
payload["org"]["role"] = "FINANCE_ADMIN"
payload["canChangePlan"] = True
state = billing_state_from_payload(payload)
assert state.is_admin is False
assert state.can_change_plan is True
assert state.can_charge is True
def test_state_owner_tier_parse():
@ -216,105 +155,18 @@ def test_state_owner_tier_parse():
)
def test_state_parses_link_payment_method():
payload = _owner_payload()
payload["paymentMethod"] = {
"kind": "link",
"email": "billing@example.com",
"paymentMethodId": "pm_secret",
"purpose": "top-up",
"resolvedVia": "customerDefault",
}
state = billing_state_from_payload(payload)
assert state.payment_method == PaymentMethodInfo(
kind="link",
email="billing@example.com",
resolved_via="customerDefault",
)
def test_state_without_payment_method_keeps_it_absent():
state = billing_state_from_payload(_owner_payload())
assert state.payment_method is None
@pytest.mark.parametrize("raw_payment_method", ["link", {"email": "billing@example.com"}])
def test_state_ignores_malformed_payment_method(raw_payment_method):
payload = _owner_payload()
payload["paymentMethod"] = raw_payment_method
state = billing_state_from_payload(payload)
assert state.payment_method is None
@pytest.mark.parametrize(
"raw_card,expected",
[
(
{"kind": "canonical", "paymentMethodId": "ignored", "brand": "ignored"},
AutoReloadCard(kind="canonical"),
),
(
{
"kind": "distinct",
"paymentMethodId": "pm_auto",
"brand": None,
"last4": None,
},
AutoReloadCard(
kind="distinct",
payment_method_id="pm_auto",
brand=None,
last4=None,
),
),
(
{"kind": "none", "last4": "ignored"},
AutoReloadCard(kind="none"),
),
],
)
def test_state_parses_auto_reload_card_variants(raw_card, expected):
payload = _owner_payload()
payload["autoReload"]["card"] = raw_card
state = billing_state_from_payload(payload)
assert state.auto_reload is not None
assert state.auto_reload.card == expected
@pytest.mark.parametrize("raw_card", [None, "canonical", {}, {"kind": "future"}])
def test_state_ignores_unrecognized_auto_reload_card(raw_card):
payload = _owner_payload()
payload["autoReload"]["card"] = raw_card
state = billing_state_from_payload(payload)
assert state.auto_reload is not None
assert state.auto_reload.card is None
def test_state_can_charge_false_when_killswitch_off():
p = _owner_payload()
p["cliBillingEnabled"] = False
s = billing_state_from_payload(p)
assert s.is_admin is True
assert s.can_charge is False # kill-switch off gates the action
def test_state_handles_garbage_substructs():
p = _member_payload()
p["card"] = "not-a-dict"
p["monthlyCap"] = 42
p["chargePresets"] = ["100", "bad", "250"] # bad preset dropped, not crash
s = billing_state_from_payload(p)
assert s.card is None and s.monthly_cap is None
assert s.charge_presets == (Decimal("100"), Decimal("250"))
# ---------------------------------------------------------------------------
@ -345,17 +197,6 @@ def test_403_insufficient_scope_maps_to_scope_required():
assert (ei.value.portal_url or "").endswith("/billing")
@pytest.mark.parametrize("status", [429, 503])
def test_rate_limited_maps_with_retry_after(status):
with pytest.raises(BillingRateLimited) as ei:
_raise_for_error(
status,
{"error": "rate_limited"},
_Headers({"Retry-After": "60"}),
)
assert ei.value.retry_after == 60
# Critically: a rate limit is NOT a generic BillingError-only — surfaces branch on type.
assert isinstance(ei.value, BillingRateLimited)
@pytest.mark.parametrize(
@ -381,40 +222,8 @@ def test_specific_billing_throttle_errors_remain_distinguishable(
assert not isinstance(ei.value, BillingRateLimited)
@pytest.mark.parametrize(
"error",
[
"no_payment_method",
"cli_billing_disabled",
"role_required",
"monthly_cap_exceeded",
"org_access_denied",
],
)
def test_other_403s_map_to_base_error_with_portal_url(error):
with pytest.raises(BillingError) as ei:
_raise_for_error(403, {"error": error, "portalUrl": "/billing?topup=open"})
# Not a scope/auth/rate subclass — the generic gate-denial path.
assert not isinstance(ei.value, (BillingScopeRequired, BillingAuthError, BillingRateLimited))
assert ei.value.error == error
# portalUrl resolved to an absolute deep-link (server sends it relative).
assert (ei.value.portal_url or "").startswith("http")
assert (ei.value.portal_url or "").endswith("/billing?topup=open")
def test_monthly_cap_exceeded_carries_remaining_in_payload():
with pytest.raises(BillingError) as ei:
_raise_for_error(
403,
{
"error": "monthly_cap_exceeded",
"remainingUsd": "12.50",
"isDefaultCeiling": True,
"portalUrl": "/billing",
},
)
assert ei.value.payload["remainingUsd"] == "12.50"
assert ei.value.payload["isDefaultCeiling"] is True
def test_400_amount_out_of_bounds_is_base_error():
@ -429,16 +238,8 @@ def test_400_amount_out_of_bounds_is_base_error():
# ---------------------------------------------------------------------------
def test_post_charge_requires_idempotency_key():
with pytest.raises(BillingError) as ei:
nb.post_charge(amount_usd=50, idempotency_key="")
assert ei.value.error == "idempotency_key_required"
def test_get_charge_status_requires_id():
with pytest.raises(BillingError) as ei:
nb.get_charge_status("")
assert ei.value.error == "invalid_charge_id"
# ---------------------------------------------------------------------------
@ -446,18 +247,8 @@ def test_get_charge_status_requires_id():
# ---------------------------------------------------------------------------
def test_portal_base_url_env_override(monkeypatch):
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", "https://preview.example.com/")
assert resolve_portal_base_url() == "https://preview.example.com"
def test_portal_base_url_falls_back_to_state(monkeypatch):
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
assert (
resolve_portal_base_url({"portal_base_url": "https://stored.example.com/"})
== "https://stored.example.com"
)
def test_portal_base_url_default(monkeypatch):
@ -471,14 +262,6 @@ def test_portal_base_url_default(monkeypatch):
# ---------------------------------------------------------------------------
def test_build_billing_state_logged_out_on_auth_error(monkeypatch):
def _auth(*a, **kw):
raise BillingAuthError("nope", status=401)
monkeypatch.setattr(nb, "get_billing_state", _auth)
s = build_billing_state()
assert s.logged_in is False
assert s.error is None # cleanly logged out, not an error
def test_build_billing_state_fail_open_on_http_error(monkeypatch):
@ -491,23 +274,8 @@ def test_build_billing_state_fail_open_on_http_error(monkeypatch):
assert "portal exploded" in (s.error or "")
def test_build_billing_state_parses_and_prefers_server_portal_url(monkeypatch):
payload = _owner_payload()
payload["portalUrl"] = "https://portal.example.com/billing?topup=open"
monkeypatch.setattr(nb, "get_billing_state", lambda *a, **kw: payload)
s = build_billing_state()
assert s.logged_in is True
assert s.portal_url == "https://portal.example.com/billing?topup=open"
assert s.balance_usd == Decimal("142.5")
def test_build_billing_state_builds_fallback_portal_url(monkeypatch):
payload = _member_payload() # no portalUrl key
monkeypatch.setattr(nb, "get_billing_state", lambda *a, **kw: payload)
monkeypatch.setattr(bv, "_fallback_portal_url", lambda base: "FALLBACK")
# resolve_portal_base_url is imported into bv via local import; patch nb's.
s = build_billing_state()
assert s.portal_url == "FALLBACK"
# ---------------------------------------------------------------------------
@ -526,14 +294,8 @@ def test_new_idempotency_key_unique_and_uuid_shaped():
# ---------------------------------------------------------------------------
def test_validate_amount_ok():
v = validate_charge_amount("100", min_usd=Decimal("10"), max_usd=Decimal("10000"))
assert v.ok and v.amount == Decimal("100")
def test_validate_amount_strips_dollar_sign():
v = validate_charge_amount("$250", min_usd=Decimal("10"), max_usd=Decimal("10000"))
assert v.ok and v.amount == Decimal("250")
@pytest.mark.parametrize(
@ -558,10 +320,6 @@ def test_validate_amount_rejections(raw, err_substr):
# ---------------------------------------------------------------------------
def test_billing_fixture_unset_returns_none(monkeypatch):
"""No env var → fixture is inert (the real portal path runs)."""
monkeypatch.delenv("HERMES_DEV_BILLING_FIXTURE", raising=False)
assert bv._dev_fixture_billing_state() is None
@pytest.mark.parametrize(
@ -584,17 +342,5 @@ def test_billing_fixture_card_and_gate_invariants(monkeypatch, name, has_card, i
assert s.cli_billing_enabled is billing_on
def test_billing_fixture_autoreload_state(monkeypatch):
"""card-autoreload pairs a card with an enabled auto-reload (drives that screen)."""
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "card-autoreload")
s = build_billing_state()
assert s.card is not None
assert s.auto_reload is not None and s.auto_reload.enabled is True
def test_billing_fixture_logged_out_and_unknown(monkeypatch):
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "logged-out")
assert build_billing_state().logged_in is False
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "bogus-state")
s = build_billing_state()
assert s.logged_in is False and "bogus-state" in (s.error or "")

View file

@ -116,36 +116,12 @@ def test_oversize_body_is_capped(server_base, client):
assert elapsed < 9.0
def test_stalled_body_hits_hard_deadline(server_base, client):
start = time.monotonic()
with client.stream("POST", server_base + "/stall") as response:
text = read_streaming_error_body(
response, max_bytes=64 * 1024, timeout_s=2.0
)
elapsed = time.monotonic() - start
# Partial bytes that arrived before the stall are preserved.
assert "partial failure detail" in text
# The hard deadline bounds the read; we must not wait for the server stall.
assert elapsed < 5.0
def test_normal_error_body_read_intact(server_base, client):
with client.stream("POST", server_base + "/normal") as response:
text = read_streaming_error_body(response)
parsed = json.loads(text)
assert parsed["error"]["status"] == "RESOURCE_EXHAUSTED"
def test_empty_body_returns_empty_string(server_base, client):
with client.stream("POST", server_base + "/empty") as response:
text = read_streaming_error_body(response)
assert text == ""
def test_or_default_returns_none_on_empty(server_base, client):
with client.stream("POST", server_base + "/empty") as response:
result = read_error_body_or_default(response)
assert result is None
def test_or_default_returns_text_when_present(server_base, client):

View file

@ -32,8 +32,6 @@ import pytest
from agent import chat_completion_helpers as cch
class _FakeInterruptError(Exception):
"""Stand-in for the transport error a force-close raises on the worker."""
def _make_agent():
@ -79,59 +77,8 @@ def test_non_streaming_cancel_does_not_surface_network_error():
assert elapsed < 10.0, f"interrupt took {elapsed:.1f}s — should be near-instant (guarding the 30s+ hang)"
def test_normal_transient_error_still_raises_when_not_cancelled():
"""Regression guard: a real transport error with NO interrupt must still
surface to the caller (so the outer retry loop can recover)."""
agent = _make_agent()
fake_client = MagicMock()
fake_client.chat.completions.create.side_effect = httpx.RemoteProtocolError(
"genuine network drop"
)
agent._create_request_openai_client.return_value = fake_client
agent._close_request_openai_client = MagicMock()
agent._abort_request_openai_client = MagicMock()
agent._interrupt_requested = False
with pytest.raises(httpx.RemoteProtocolError):
cch.interruptible_api_call(agent, {"model": "x", "messages": []})
def test_request_cancelled_token_is_request_local():
"""The cancellation token must be created per call, not shared on the
agent a stale worker from a previous turn must not see the next turn's
interrupt flag flip back to False and mistake its own forced error for a
network bug. We assert the helper reads agent._interrupt_requested at the
force-close site (request-local token set there), by confirming two
independent calls don't share cancellation state."""
agent = _make_agent()
# First call: interrupted.
fake_client_1 = MagicMock()
def _create_1(**kwargs):
agent._interrupt_requested = True
time.sleep(0.3)
raise httpx.RemoteProtocolError("forced close turn A")
fake_client_1.chat.completions.create.side_effect = _create_1
agent._create_request_openai_client.return_value = fake_client_1
agent._close_request_openai_client = MagicMock()
agent._abort_request_openai_client = MagicMock()
with pytest.raises(InterruptedError):
cch.interruptible_api_call(agent, {"model": "x", "messages": []})
# Second call: NOT interrupted (turn boundary cleared the flag). A genuine
# error must still surface — the previous call's cancellation must not leak.
agent._interrupt_requested = False
fake_client_2 = MagicMock()
fake_client_2.chat.completions.create.side_effect = httpx.RemoteProtocolError(
"genuine drop turn B"
)
agent._create_request_openai_client.return_value = fake_client_2
with pytest.raises(httpx.RemoteProtocolError):
cch.interruptible_api_call(agent, {"model": "x", "messages": []})
# ---------------------------------------------------------------------------
@ -193,32 +140,3 @@ def test_anthropic_non_streaming_stale_aborts_request_client_not_shared():
_wait_for_mock_call(agent._close_request_anthropic_client)
def test_anthropic_non_streaming_interrupt_aborts_request_client_not_shared():
"""Interrupted non-streaming Anthropic call: near-instant InterruptedError,
request-local client aborted from the poll thread, shared client untouched."""
agent = _make_anthropic_agent()
request_client = MagicMock()
agent._create_request_anthropic_client = MagicMock(return_value=request_client)
agent._abort_request_anthropic_client = MagicMock()
agent._close_request_anthropic_client = MagicMock()
def _create(_api_kwargs, *, client):
assert client is request_client
agent._interrupt_requested = True
time.sleep(1.0)
raise httpx.RemoteProtocolError("forced close would have happened")
agent._anthropic_messages_create = MagicMock(side_effect=_create)
t0 = time.time()
with pytest.raises(InterruptedError):
cch.interruptible_api_call(agent, {"model": "x", "messages": []})
elapsed = time.time() - t0
assert elapsed < 3.0, f"interrupt took {elapsed:.1f}s — should be near-instant"
agent._anthropic_client.close.assert_not_called()
agent._rebuild_anthropic_client.assert_not_called()
agent._abort_request_anthropic_client.assert_called_once_with(
request_client, reason="interrupt_abort"
)

View file

@ -8,9 +8,6 @@ from agent.model_metadata import (
)
def test_cjk_text_is_not_estimated_as_four_chars_per_token():
assert estimate_tokens_rough("a" * 400) == 100
assert estimate_tokens_rough("" * 400) >= 400
def test_message_estimate_counts_korean_content_as_token_dense():
@ -19,11 +16,6 @@ def test_message_estimate_counts_korean_content_as_token_dense():
assert estimate_messages_tokens_rough(messages) >= 1000
def test_compressor_tail_budget_uses_cjk_aware_message_estimate():
korean_msg = {"role": "assistant", "content": "" * 2000}
english_msg = {"role": "assistant", "content": "a" * 2000}
assert _estimate_msg_budget_tokens(korean_msg) > _estimate_msg_budget_tokens(english_msg)
def test_cjk_tail_does_not_expand_to_english_char_budget():
@ -85,12 +77,5 @@ def test_perf_gated_estimator_matches_per_char_reference():
assert estimate_tokens_rough(text) == _reference_per_char_estimate(text), repr(text)
def test_ascii_fast_path_keeps_classic_four_chars_per_token():
# Pure ASCII must be bit-identical to the historical (len+3)//4 rule.
for text in ("x", "xyz", "a" * 1000, "tool output\n" * 500):
assert estimate_tokens_rough(text) == (len(text) + 3) // 4
def test_non_ascii_non_cjk_keeps_classic_rule():
text = "café résumé " * 40
assert estimate_tokens_rough(text) == (len(text) + 3) // 4

View file

@ -35,24 +35,10 @@ def _assert_no_tool_then_user(messages):
)
def test_tool_tail_is_closed_with_placeholder():
messages = _tool_tail()
assert close_interrupted_tool_sequence(messages, None) is True
assert messages[-1]["role"] == "assistant"
assert messages[-1]["content"] == "Operation interrupted."
def test_tool_tail_keeps_interrupt_text_when_present():
messages = _tool_tail()
close_interrupted_tool_sequence(messages, "Operation interrupted during retry (attempt 2/3).")
assert messages[-1]["role"] == "assistant"
assert messages[-1]["content"] == "Operation interrupted during retry (attempt 2/3)."
def test_blank_interrupt_text_falls_back_to_placeholder():
messages = _tool_tail()
close_interrupted_tool_sequence(messages, " ")
assert messages[-1]["content"] == "Operation interrupted."
def test_closing_makes_next_user_message_alternation_safe():
@ -80,7 +66,3 @@ def test_user_tail_is_left_untouched():
assert len(messages) == 1
def test_empty_messages_is_noop():
messages = []
assert close_interrupted_tool_sequence(messages, "x") is False
assert messages == []

View file

@ -54,25 +54,9 @@ def _item_completed(item: dict) -> dict:
class TestCodexItemToToolName:
def test_command_execution_maps_to_exec_command(self):
assert _codex_item_to_tool_name(
{"type": "commandExecution"}
) == "exec_command"
def test_file_change_maps_to_apply_patch(self):
assert _codex_item_to_tool_name(
{"type": "fileChange"}
) == "apply_patch"
def test_mcp_tool_call_includes_server_and_tool(self):
assert _codex_item_to_tool_name(
{"type": "mcpToolCall", "server": "fs", "tool": "read_file"}
) == "mcp.fs.read_file"
def test_mcp_tool_call_falls_back_when_fields_missing(self):
assert _codex_item_to_tool_name(
{"type": "mcpToolCall"}
) == "mcp.mcp.unknown"
def test_dynamic_tool_call_uses_tool_field(self):
assert _codex_item_to_tool_name(
@ -91,27 +75,11 @@ class TestCodexItemToToolName:
{"type": "mcpToolCall", "server": "hermes-tools", "tool": "browser_navigate"}
) == "browser_navigate"
def test_web_search_builtin_maps_to_web_search(self):
"""Codex's built-in webSearch tool gets a bubble too (#26541)."""
assert _codex_item_to_tool_name({"type": "webSearch"}) == "web_search"
def test_unknown_type_returns_type_string(self):
assert _codex_item_to_tool_name(
{"type": "plan"}
) == "plan"
def test_missing_type_returns_unknown_sentinel(self):
assert _codex_item_to_tool_name({}) == "unknown"
class TestCodexItemToArgs:
def test_command_execution_args_carry_cwd_and_command(self):
args = _codex_item_to_args({
"type": "commandExecution",
"command": "ls -la",
"cwd": "/tmp",
})
assert args == {"command": "ls -la", "cwd": "/tmp"}
def test_file_change_args_normalize_changes(self):
args = _codex_item_to_args({
@ -129,11 +97,6 @@ class TestCodexItemToArgs:
]
}
def test_mcp_tool_call_returns_arguments_dict(self):
args = _codex_item_to_args({
"type": "mcpToolCall", "arguments": {"q": "x"}
})
assert args == {"q": "x"}
def test_non_dict_arguments_get_wrapped(self):
args = _codex_item_to_args({
@ -162,20 +125,8 @@ class TestCodexItemToPreview:
assert "/p0.py" in preview and "/p2.py" in preview
assert "+2 more" in preview
def test_file_change_no_paths_returns_none(self):
assert _codex_item_to_preview({
"type": "fileChange", "changes": [{}]
}) is None
def test_mcp_args_preview_is_json(self):
preview = _codex_item_to_preview({
"type": "mcpToolCall", "arguments": {"q": "hello"},
})
assert preview is not None
assert "hello" in preview
def test_empty_args_returns_none(self):
assert _codex_item_to_preview({"type": "mcpToolCall"}) is None
class TestCodexItemCompletionPayload:
@ -188,24 +139,7 @@ class TestCodexItemCompletionPayload:
assert result == "hello\nworld\n"
assert is_error is False
def test_command_nonzero_exit_marks_error(self):
result, is_error = _codex_item_completion_payload({
"type": "commandExecution",
"exitCode": 2,
"aggregatedOutput": "boom",
})
assert "[exit 2]" in result
assert "boom" in result
assert is_error is True
def test_file_change_completed_status_not_error(self):
result, is_error = _codex_item_completion_payload({
"type": "fileChange",
"status": "completed",
"changes": [{"path": "/a"}],
})
assert "completed" in result
assert is_error is False
def test_mcp_tool_error_is_error(self):
result, is_error = _codex_item_completion_payload({
@ -215,13 +149,6 @@ class TestCodexItemCompletionPayload:
assert "[error]" in result
assert is_error is True
def test_dynamic_tool_failure_is_error(self):
result, is_error = _codex_item_completion_payload({
"type": "dynamicToolCall",
"success": False,
})
assert "False" in result
assert is_error is True
# ---------- bridge: dispatch contracts ----------
@ -239,19 +166,7 @@ class TestStreamDeltaDispatch:
assert agent._fire_stream_delta.call_args_list[0].args == ("hello ",)
assert agent._fire_stream_delta.call_args_list[1].args == ("world",)
def test_empty_delta_is_skipped(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge({"method": "item/agentMessage/delta", "params": {"delta": ""}})
bridge({"method": "item/agentMessage/delta", "params": {}})
agent._fire_stream_delta.assert_not_called()
def test_text_field_used_when_delta_missing(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge({"method": "item/agentMessage/delta",
"params": {"text": "fallback"}})
agent._fire_stream_delta.assert_called_once_with("fallback")
def test_reasoning_delta_fires_reasoning_callback(self):
agent = _make_stub_agent()
@ -306,83 +221,9 @@ class TestToolProgressDispatch:
assert completed.kwargs["is_error"] is False
assert completed.kwargs["result"] == "hi\n"
def test_nonzero_exit_marks_completion_error(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge(_item_completed({
"type": "commandExecution",
"id": "exec-3",
"exitCode": 127,
"aggregatedOutput": "not found",
}))
call = agent.tool_progress_callback.call_args
assert call.args[0] == "tool.completed"
assert call.kwargs["is_error"] is True
assert "[exit 127]" in call.kwargs["result"]
def test_apply_patch_started_and_completed(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge(_item_started({
"type": "fileChange",
"id": "fc-1",
"changes": [
{"path": "/a.py", "kind": {"type": "add"}},
{"path": "/b.py", "kind": {"type": "update"}},
],
}))
bridge(_item_completed({
"type": "fileChange",
"id": "fc-1",
"status": "completed",
"changes": [{"path": "/a.py"}, {"path": "/b.py"}],
}))
names = [
c.args[1] for c in agent.tool_progress_callback.call_args_list
]
assert names == ["apply_patch", "apply_patch"]
completed = agent.tool_progress_callback.call_args_list[1]
assert completed.kwargs["is_error"] is False
assert "2 change(s)" in completed.kwargs["result"]
def test_mcp_tool_uses_namespaced_tool_name(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge(_item_started({
"type": "mcpToolCall",
"id": "mcp-1",
"server": "fs",
"tool": "list_dir",
"arguments": {"path": "/tmp"},
}))
call = agent.tool_progress_callback.call_args
assert call.args[1] == "mcp.fs.list_dir"
# Preview should be a json render of the args
assert "/tmp" in call.args[2]
def test_dynamic_tool_uses_tool_field_as_name(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge(_item_started({
"type": "dynamicToolCall",
"id": "dyn-1",
"tool": "web_search",
"arguments": {"query": "hermes"},
}))
bridge(_item_completed({
"type": "dynamicToolCall",
"id": "dyn-1",
"tool": "web_search",
"success": True,
"contentItems": [{"text": "results"}],
}))
names = [
c.args[1] for c in agent.tool_progress_callback.call_args_list
]
assert names == ["web_search", "web_search"]
completed = agent.tool_progress_callback.call_args_list[1]
assert completed.kwargs["is_error"] is False
assert "results" in completed.kwargs["result"]
def test_web_search_builtin_fires_started_and_completed(self):
"""Codex's built-in webSearch produces a start/complete bubble pair
@ -405,30 +246,7 @@ class TestToolProgressDispatch:
assert calls[0].args[2] == "hermes agent docs"
assert calls[0].args[3] == {"query": "hermes agent docs"}
def test_duration_falls_back_to_wall_time_when_codex_missing_ms(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge(_item_started({
"type": "commandExecution",
"id": "exec-4",
"command": "sleep 0",
}))
bridge(_item_completed({
"type": "commandExecution",
"id": "exec-4",
"exitCode": 0,
"aggregatedOutput": "",
# no durationMs
}))
completed = agent.tool_progress_callback.call_args_list[1]
assert completed.kwargs["duration"] is not None
assert completed.kwargs["duration"] >= 0
def test_unknown_started_item_type_is_silent(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge(_item_started({"type": "plan", "id": "p-1"}))
agent.tool_progress_callback.assert_not_called()
class TestAgentMessageInterimDispatch:
@ -444,24 +262,7 @@ class TestAgentMessageInterimDispatch:
{"role": "assistant", "content": "I'll check the config first."}
)
def test_empty_text_does_not_emit_interim(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge(_item_completed({
"type": "agentMessage", "id": "am-2", "text": " ",
}))
bridge(_item_completed({
"type": "agentMessage", "id": "am-3", "text": ""
}))
agent._emit_interim_assistant_message.assert_not_called()
def test_completed_agent_message_does_not_fire_tool_progress(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge(_item_completed({
"type": "agentMessage", "id": "am-4", "text": "hi",
}))
agent.tool_progress_callback.assert_not_called()
def test_show_commentary_off_suppresses_interim(self):
"""display.show_commentary=false silences agentMessage interim
@ -482,15 +283,6 @@ class TestAgentMessageInterimDispatch:
class TestBridgeRobustness:
def test_non_dict_notification_is_ignored(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge("not-a-dict") # type: ignore[arg-type]
bridge(None) # type: ignore[arg-type]
bridge(123) # type: ignore[arg-type]
agent.tool_progress_callback.assert_not_called()
agent._fire_stream_delta.assert_not_called()
agent._emit_interim_assistant_message.assert_not_called()
def test_missing_params_is_ignored(self):
agent = _make_stub_agent()
@ -516,36 +308,7 @@ class TestBridgeRobustness:
"type": "agentMessage", "id": "am-x", "text": "hi",
}))
def test_agent_without_callbacks_is_a_noop(self):
# Mirrors gateway-less / cron contexts where the agent never had
# the display callbacks set. Bridge must not raise.
agent = SimpleNamespace() # bare — none of the callbacks exist
bridge = make_codex_app_server_event_bridge(agent)
bridge(_item_started({
"type": "commandExecution", "id": "exec-y", "command": "ls",
}))
bridge(_item_completed({
"type": "commandExecution", "id": "exec-y",
"exitCode": 0, "aggregatedOutput": "",
}))
bridge({"method": "item/agentMessage/delta",
"params": {"delta": "x"}})
bridge({"method": "item/reasoning/delta",
"params": {"delta": "x"}})
bridge(_item_completed({
"type": "agentMessage", "id": "am", "text": "hi",
}))
def test_silent_methods_do_not_fire_anything(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
for method in ("turn/started", "turn/completed", "thread/started",
"item/commandExecution/outputDelta"):
bridge({"method": method, "params": {}})
agent.tool_progress_callback.assert_not_called()
agent._fire_stream_delta.assert_not_called()
agent._fire_reasoning_delta.assert_not_called()
agent._emit_interim_assistant_message.assert_not_called()
# ---------- end-to-end: bridge is wired in run_codex_app_server_turn ----------

View file

@ -58,22 +58,12 @@ def _make_codex_jwt(account_id: str = "acct-test-123") -> str:
# ---------------------------------------------------------------------------
class TestCodexCloudflareHeaders:
def test_originator_is_codex_cli_rs(self):
"""Cloudflare whitelists codex_cli_rs — any other value is 403'd."""
from agent.auxiliary_client import _codex_cloudflare_headers
headers = _codex_cloudflare_headers(_make_codex_jwt())
assert headers["originator"] == "codex_cli_rs"
def test_user_agent_advertises_codex_cli_rs(self):
from agent.auxiliary_client import _codex_cloudflare_headers
headers = _codex_cloudflare_headers(_make_codex_jwt())
assert headers["User-Agent"].startswith("codex_cli_rs/")
def test_account_id_extracted_from_jwt(self):
from agent.auxiliary_client import _codex_cloudflare_headers
headers = _codex_cloudflare_headers(_make_codex_jwt("acct-abc-999"))
# Canonical casing — matches codex-rs auth.rs
assert headers["ChatGPT-Account-ID"] == "acct-abc-999"
def test_canonical_header_casing(self):
"""Upstream codex-rs uses PascalCase with trailing -ID. Match exactly."""
@ -84,19 +74,7 @@ class TestCodexCloudflareHeaders:
assert "chatgpt-account-id" not in headers
assert "ChatGPT-Account-Id" not in headers
def test_malformed_token_drops_account_id_without_raising(self):
from agent.auxiliary_client import _codex_cloudflare_headers
for bad in ["not-a-jwt", "", "only.one", " ", "...."]:
headers = _codex_cloudflare_headers(bad)
# Still returns base headers — never raises
assert headers["originator"] == "codex_cli_rs"
assert "ChatGPT-Account-ID" not in headers
def test_non_string_token_handled(self):
from agent.auxiliary_client import _codex_cloudflare_headers
headers = _codex_cloudflare_headers(None) # type: ignore[arg-type]
assert headers["originator"] == "codex_cli_rs"
assert "ChatGPT-Account-ID" not in headers
def test_jwt_without_chatgpt_account_id_claim(self):
"""A valid JWT that lacks the account_id claim should still return headers."""
@ -117,24 +95,6 @@ class TestCodexCloudflareHeaders:
# ---------------------------------------------------------------------------
class TestPrimaryClientWiring:
def test_init_wires_codex_headers_for_chatgpt_base_url(self):
from run_agent import AIAgent
token = _make_codex_jwt("acct-primary-init")
with patch("run_agent.OpenAI") as mock_openai:
mock_openai.return_value = MagicMock()
AIAgent(
api_key=token,
base_url="https://chatgpt.com/backend-api/codex",
provider="openai-codex",
model="gpt-5.4",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
headers = mock_openai.call_args.kwargs.get("default_headers") or {}
assert headers.get("originator") == "codex_cli_rs"
assert headers.get("ChatGPT-Account-ID") == "acct-primary-init"
assert headers.get("User-Agent", "").startswith("codex_cli_rs/")
def test_apply_client_headers_on_base_url_change(self):
"""Credential-rotation / base-url change path must also emit codex headers."""
@ -184,21 +144,6 @@ class TestPrimaryClientWiring:
# default_headers should be popped for anthropic base
assert "default_headers" not in agent._client_kwargs
def test_openrouter_base_url_does_not_get_codex_headers(self):
from run_agent import AIAgent
with patch("run_agent.OpenAI") as mock_openai:
mock_openai.return_value = MagicMock()
AIAgent(
api_key="sk-or-test",
base_url="https://openrouter.ai/api/v1",
provider="openrouter",
model="anthropic/claude-sonnet-4.6",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
headers = mock_openai.call_args.kwargs.get("default_headers") or {}
assert headers.get("originator") != "codex_cli_rs"
# ---------------------------------------------------------------------------

View file

@ -89,24 +89,8 @@ def _threshold_ratio(agent: AIAgent) -> float:
# ── config display gate ──────────────────────────────────────────────────────
def test_codex_gpt55_autoraise_notice_enabled_by_default(monkeypatch, tmp_path):
agent, stdout = _make_codex_agent(monkeypatch, tmp_path, show_notice=True)
assert _threshold_ratio(agent) == 0.85
warning = getattr(agent, "_compression_warning")
assert warning is not None
assert "auto-compaction was raised" in warning
assert "auto-compaction was raised" in stdout
def test_codex_gpt55_autoraise_notice_can_be_suppressed_without_disabling_autoraise(
monkeypatch, tmp_path
):
agent, stdout = _make_codex_agent(monkeypatch, tmp_path, show_notice=False)
assert _threshold_ratio(agent) == 0.85
assert getattr(agent, "_compression_warning") is None
assert "auto-compaction was raised" not in stdout
def test_codex_gpt55_autoraise_notice_deduped_across_agent_inits(monkeypatch, tmp_path):
@ -132,27 +116,10 @@ def test_marker_lives_under_hermes_home() -> None:
assert marker.name == ".codex_gpt55_autoraise_notice"
def test_state_keyed_on_model_and_displayed_percentages() -> None:
# Same percentages the notice text renders (int(round(ratio * 100))),
# prefixed with the bare model slug.
assert _codex_gpt55_autoraise_notice_state(AUTORAISE) == "gpt-5.5:50:85"
assert (
_codex_gpt55_autoraise_notice_state(
{"model": "openai/gpt-5.4", "from": 0.75, "to": 0.85}
)
== "gpt-5.4:75:85"
)
def test_unseen_before_anything_is_recorded() -> None:
assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False
def test_seen_after_record() -> None:
assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False
_record_codex_gpt55_autoraise_notice(AUTORAISE)
# A "restart" is just another call: the marker persists on disk.
assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is True
def test_changed_threshold_renotifies_once() -> None:
@ -167,36 +134,12 @@ def test_changed_threshold_renotifies_once() -> None:
assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False
def test_changed_model_renotifies_once() -> None:
# Switching to a different autoraised Codex model re-fires the notice
# (the banner names the model, so it displays new information).
_record_codex_gpt55_autoraise_notice(AUTORAISE)
other_model = {"model": "gpt-5.4", "from": 0.50, "to": 0.85}
assert _codex_gpt55_autoraise_notice_seen(other_model) is False
_record_codex_gpt55_autoraise_notice(other_model)
assert _codex_gpt55_autoraise_notice_seen(other_model) is True
def test_record_is_idempotent() -> None:
_record_codex_gpt55_autoraise_notice(AUTORAISE)
_record_codex_gpt55_autoraise_notice(AUTORAISE)
assert (
_codex_gpt55_autoraise_notice_marker().read_text(encoding="utf-8")
== "gpt-5.5:50:85"
)
def test_malformed_marker_reads_as_unseen() -> None:
marker = _codex_gpt55_autoraise_notice_marker()
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("not-a-state", encoding="utf-8")
assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False
@pytest.mark.parametrize("bad", [{}, {"from": 0.5}, {"from": None, "to": None}])
def test_seen_tolerates_malformed_autoraise(bad) -> None:
# Never raises even if the stashed dict is missing/garbage keys.
assert _codex_gpt55_autoraise_notice_seen(bad) is False
def test_full_init_gate_shows_once_then_stays_silent() -> None:

View file

@ -11,43 +11,6 @@ from agent.codex_responses_adapter import (
)
def test_normalize_codex_response_drops_transient_rs_tmp_reasoning_items():
response = SimpleNamespace(
status="completed",
output=[
SimpleNamespace(
type="reasoning",
id="rs_tmp_123",
encrypted_content="opaque-transient",
summary=[],
),
SimpleNamespace(
type="reasoning",
id="rs_456",
encrypted_content="opaque-stable",
summary=[SimpleNamespace(text="stable summary")],
),
SimpleNamespace(
type="message",
role="assistant",
status="completed",
content=[SimpleNamespace(type="output_text", text="done")],
),
],
)
assistant_message, finish_reason = _normalize_codex_response(response)
assert finish_reason == "stop"
assert assistant_message.content == "done"
assert assistant_message.codex_reasoning_items == [
{
"type": "reasoning",
"encrypted_content": "opaque-stable",
"id": "rs_456",
"summary": [{"type": "summary_text", "text": "stable summary"}],
}
]
def test_normalize_codex_response_treats_summary_only_reasoning_as_incomplete():
@ -79,19 +42,6 @@ def test_normalize_codex_response_treats_summary_only_reasoning_as_incomplete():
assert assistant_message.codex_reasoning_items is None
def test_normalize_codex_response_maps_incomplete_content_filter_to_refusal():
response = SimpleNamespace(
status="incomplete",
incomplete_details=SimpleNamespace(reason="content_filter"),
output=[],
output_text="",
)
assistant_message, finish_reason = _normalize_codex_response(response)
assert finish_reason == "content_filter"
assert assistant_message.content == ""
assert response.output
# ---------------------------------------------------------------------------
@ -108,60 +58,8 @@ def test_normalize_codex_response_maps_incomplete_content_filter_to_refusal():
# ---------------------------------------------------------------------------
def test_normalize_codex_response_ignores_in_progress_server_side_tool_calls():
"""A completed response with a final message + lingering in_progress
server-side web_search_call items resolves to 'stop', not 'incomplete'."""
response = SimpleNamespace(
status="completed",
incomplete_details=None,
output=[
SimpleNamespace(
type="reasoning",
id="rs_1",
encrypted_content="opaque",
summary=[SimpleNamespace(text="researching blades")],
),
SimpleNamespace(
type="message",
role="assistant",
status="completed",
content=[SimpleNamespace(
type="output_text",
text="Milwaukee M18 blade 49-16-2734, ~$30 OEM.",
)],
),
SimpleNamespace(type="web_search_call", status="in_progress"),
SimpleNamespace(type="web_search_call", status="in_progress"),
SimpleNamespace(type="web_search_call", status="in_progress"),
],
)
assistant_message, finish_reason = _normalize_codex_response(response)
assert finish_reason == "stop"
assert assistant_message.content == "Milwaukee M18 blade 49-16-2734, ~$30 OEM."
def test_normalize_codex_response_in_progress_message_still_incomplete():
"""Guard scope: an in_progress *message* item (genuine model output that
is still streaming) must still mark the turn incomplete only
server-side ``*_call`` items are exempted."""
response = SimpleNamespace(
status="completed",
incomplete_details=None,
output=[
SimpleNamespace(
type="message",
role="assistant",
status="in_progress",
content=[SimpleNamespace(type="output_text", text="partial...")],
),
],
)
_assistant_message, finish_reason = _normalize_codex_response(response)
assert finish_reason == "incomplete"
# ---------------------------------------------------------------------------
@ -178,53 +76,8 @@ _OVERSIZED_ITEM_ID = "x" * 408
_VALID_ITEM_ID = "msg_abc123"
def test_chat_messages_to_responses_input_drops_oversized_message_id():
messages = [
{
"role": "assistant",
"content": "pong",
"codex_message_items": [
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "pong"}],
"id": _OVERSIZED_ITEM_ID,
"phase": "final_answer",
}
],
}
]
items = _chat_messages_to_responses_input(messages)
message_item = next(item for item in items if item.get("type") == "message")
assert "id" not in message_item
assert message_item["phase"] == "final_answer"
assert message_item["content"] == [{"type": "output_text", "text": "pong"}]
def test_chat_messages_to_responses_input_keeps_short_message_id():
messages = [
{
"role": "assistant",
"content": "pong",
"codex_message_items": [
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "pong"}],
"id": _VALID_ITEM_ID,
}
],
}
]
items = _chat_messages_to_responses_input(messages)
message_item = next(item for item in items if item.get("type") == "message")
assert message_item["id"] == _VALID_ITEM_ID
# The codex app-server overflows the Responses 64-char call_id limit for
@ -293,38 +146,8 @@ def test_chat_messages_to_responses_input_keeps_short_call_id():
assert output["call_id"] == "call_abc123"
def test_preflight_codex_input_items_drops_oversized_message_id():
items = _preflight_codex_input_items(
[
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "pong"}],
"id": _OVERSIZED_ITEM_ID,
"phase": "final_answer",
}
]
)
assert "id" not in items[0]
assert items[0]["phase"] == "final_answer"
def test_preflight_codex_input_items_keeps_short_message_id():
items = _preflight_codex_input_items(
[
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "pong"}],
"id": _VALID_ITEM_ID,
}
]
)
assert items[0]["id"] == _VALID_ITEM_ID
def test_preflight_codex_input_items_drops_short_id_for_github_responses():
@ -400,16 +223,6 @@ def test_preflight_passes_native_web_search_tool_through():
assert any(t.get("type") == "function" and t.get("name") == "read_file" for t in tools)
def test_preflight_still_rejects_unknown_tool_type():
kwargs = {
"model": "grok-composer-2.5-fast",
"instructions": "You are helpful.",
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
"store": False,
"tools": [{"type": "totally_made_up_tool"}],
}
with pytest.raises(ValueError, match="unsupported type"):
_preflight_codex_api_kwargs(kwargs, allow_stream=True)
# ---------------------------------------------------------------------------
@ -421,9 +234,6 @@ def test_preflight_still_rejects_unknown_tool_type():
# ---------------------------------------------------------------------------
def test_format_responses_error_combines_code_and_message():
err = {"code": "rate_limit_exceeded", "message": "Slow down"}
assert _format_responses_error(err, "failed") == "rate_limit_exceeded: Slow down"
def test_format_responses_error_message_only():
@ -431,51 +241,16 @@ def test_format_responses_error_message_only():
assert _format_responses_error(err, "failed") == "Upstream model unavailable"
def test_format_responses_error_code_only_when_message_empty():
# Some providers/proxies emit a code with an empty message body. We
# used to fall back to ``str(error_obj)`` — a dict dump — which leaked
# ``{'code': 'internal_error', 'message': ''}`` into chat output. Now
# the bare code is surfaced, which is the meaningful field.
err = {"code": "internal_error", "message": ""}
assert _format_responses_error(err, "failed") == "internal_error"
def test_format_responses_error_code_only_when_message_missing():
err = {"code": "server_error"}
assert _format_responses_error(err, "failed") == "server_error"
def test_format_responses_error_attribute_style_payload():
# SDK objects expose ``code``/``message`` as attributes rather than dict
# keys. The helper must accept both shapes since the Responses SDK
# returns SimpleNamespace-style objects on ``response.failed``.
err = SimpleNamespace(code="context_length_exceeded", message="too long")
assert _format_responses_error(err, "failed") == "context_length_exceeded: too long"
def test_format_responses_error_falls_back_to_status_when_empty():
assert (
_format_responses_error(None, "failed")
== "Responses API returned status 'failed'"
)
assert (
_format_responses_error(None, "cancelled")
== "Responses API returned status 'cancelled'"
)
def test_format_responses_error_stringifies_opaque_payload():
# Last-resort: a provider sent something that isn't a dict and has no
# code/message attributes. Surface its repr rather than swallow it
# silently — at least it's visible in logs.
assert _format_responses_error("opaque sentinel", "failed") == "opaque sentinel"
def test_format_responses_error_ignores_non_string_code_message():
# Defensive: a malformed gateway could send numbers/objects in these
# fields. We don't want to crash; we want a best-effort string.
err = {"code": 500, "message": None}
assert _format_responses_error(err, "failed") == "500"
def test_normalize_codex_response_failed_includes_code_in_error():
@ -501,23 +276,6 @@ def test_normalize_codex_response_failed_includes_code_in_error():
_normalize_codex_response(response)
def test_normalize_codex_response_failed_with_message_only():
"""Backwards-compat: a failed response with only a message field
(no code) should still surface that message verbatim."""
response = SimpleNamespace(
status="failed",
output=[
SimpleNamespace(
type="message",
role="assistant",
status="incomplete",
content=[SimpleNamespace(type="output_text", text="partial")],
),
],
error={"message": "model error"},
)
with pytest.raises(RuntimeError, match=r"^model error$"):
_normalize_codex_response(response)
# ---------------------------------------------------------------------------
@ -546,60 +304,9 @@ def _xai_reasoning_only_response(reasoning_text):
)
def test_normalize_codex_response_salvages_xai_reasoning_channel_answer():
response = _xai_reasoning_only_response(
"The process is still running.\n<response>\nAll good, process running."
)
assistant_message, finish_reason = _normalize_codex_response(
response, issuer_kind="xai_responses"
)
assert finish_reason == "stop"
assert assistant_message.content == "All good, process running."
assert assistant_message.reasoning == "The process is still running."
def test_normalize_codex_response_salvage_strips_closing_tag():
response = _xai_reasoning_only_response(
"Thinking.\n<response>The answer.</response>"
)
assistant_message, finish_reason = _normalize_codex_response(
response, issuer_kind="xai_responses"
)
assert finish_reason == "stop"
assert assistant_message.content == "The answer."
def test_normalize_codex_response_salvage_is_xai_scoped():
"""Non-xAI special-cased issuers (Codex backend) keep the reasoning-only →
incomplete classification; the Codex backend replays encrypted reasoning,
so its continuation genuinely progresses and must not be short-circuited.
Pins ``issuer_kind="codex_backend"`` explicitly: with no issuer at all,
the unrecognized-backend rule (#64434) trusts ``status="completed"`` and
returns ``stop`` that path is covered by the #64434 regression tests.
"""
response = _xai_reasoning_only_response(
"Thinking.\n<response>The answer.</response>"
)
assistant_message, finish_reason = _normalize_codex_response(
response, issuer_kind="codex_backend"
)
assert finish_reason == "incomplete"
assert assistant_message.content == ""
def test_normalize_codex_response_xai_reasoning_without_marker_stays_incomplete():
response = _xai_reasoning_only_response("Still thinking, no answer yet.")
assistant_message, finish_reason = _normalize_codex_response(
response, issuer_kind="xai_responses"
)
assert finish_reason == "incomplete"
assert assistant_message.content == ""

View file

@ -47,51 +47,8 @@ def _recording_agent():
return agent, calls
def test_agent_message_and_reasoning_deltas_are_forwarded_live():
agent, calls = _recording_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge({"method": "item/agentMessage/delta", "params": {"delta": "Working"}})
bridge({"method": "item/reasoning/delta", "params": {"delta": "Thinking"}})
bridge({"method": "item/reasoning/summaryDelta", "params": {"delta": "Summary"}})
assert calls["stream"] == ["Working"]
assert calls["reasoning"] == ["Thinking", "Summary"]
def test_command_start_and_complete_fire_both_callback_contracts():
agent, calls = _recording_agent()
bridge = make_codex_app_server_event_bridge(agent)
started = {
"type": "commandExecution",
"id": "abc123",
"command": "echo hi",
"cwd": "/tmp",
}
completed = dict(
started,
aggregatedOutput="hi\n",
exitCode=0,
durationMs=250,
)
bridge({"method": "item/started", "params": {"item": started}})
bridge({"method": "item/completed", "params": {"item": completed}})
expected_args = {"command": "echo hi", "cwd": "/tmp"}
expected_id = "codex_exec_abc123"
assert calls["tool_start"] == [(expected_id, "exec_command", expected_args)]
assert calls["tool_complete"] == [
(expected_id, "exec_command", expected_args, "hi\n")
]
assert calls["tool_progress"][0] == (
("tool.started", "exec_command", "echo hi", expected_args),
{},
)
assert calls["tool_progress"][1] == (
("tool.completed", "exec_command", None, None),
{"duration": 0.25, "is_error": False, "result": "hi\n"},
)
def test_stable_ids_match_history_projector():
@ -147,36 +104,5 @@ def test_failed_command_result_and_error_flag_are_preserved():
assert calls["tool_complete"][0][3] == "[exit 2]\nboom"
def test_non_tool_events_and_malformed_payloads_are_ignored():
agent, calls = _recording_agent()
bridge = make_codex_app_server_event_bridge(agent)
for note in (
{"method": "item/started", "params": {"item": {"type": "reasoning"}}},
{"method": "turn/completed", "params": {}},
{"method": "item/started", "params": []},
{},
None,
):
bridge(note)
assert all(not entries for entries in calls.values())
def test_one_broken_callback_does_not_hide_other_live_events():
starts = []
def broken_progress(*_args, **_kwargs):
raise RuntimeError("display consumer failed")
agent = SimpleNamespace(
tool_progress_callback=broken_progress,
tool_start_callback=lambda call_id, name, args: starts.append(
(call_id, name, args)
),
)
bridge = make_codex_app_server_event_bridge(agent)
item = {"type": "dynamicToolCall", "id": "d1", "tool": "search"}
bridge({"method": "item/started", "params": {"item": item}})
assert starts == [("codex_dyn_search_d1", "search", {})]

View file

@ -57,90 +57,8 @@ def _make_codex_agent(tmp_path, monkeypatch):
return agent
def test_ttfb_kills_when_no_stream_event(tmp_path, monkeypatch):
"""Backend accepts the connection but emits no event -> killed at the TTFB
cutoff, well before the 60s wall-clock stale timeout, with a retryable
TimeoutError and a ``codex_ttfb_kill`` close reason."""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "1")
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
monkeypatch.setattr(
agent, "_close_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
stop = {"flag": False}
def fake_hang(api_kwargs, client=None, on_first_delta=None):
# Never set _codex_stream_last_event_ts: simulate zero events arriving.
deadline = time.time() + 30
while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested:
time.sleep(0.02)
raise RuntimeError("connection closed")
monkeypatch.setattr(agent, "_run_codex_stream", fake_hang)
t0 = time.time()
try:
with pytest.raises(TimeoutError) as excinfo:
h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": "hi"})
elapsed = time.time() - t0
assert "TTFB" in str(excinfo.value)
assert "codex_ttfb_kill" in closes
# ~1s cutoff + 2s join grace; must be far under the 60s stale timeout.
assert elapsed < 15, f"TTFB watchdog took {elapsed:.1f}s"
finally:
stop["flag"] = True
def test_ttfb_default_tolerates_slow_first_event(tmp_path, monkeypatch):
"""With no env var set, the no-byte TTFB default is generous (120s), so a
request whose first stream event is merely slow (~2s of backend admission /
prefill) is NOT killed. This is the subscription-backed Codex case the tight
12s default used to abort mid-prefill."""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
# Default behavior: no explicit TTFB override.
monkeypatch.delenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", raising=False)
monkeypatch.delenv("HERMES_CODEX_TTFB_MAX_SECONDS", raising=False)
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
monkeypatch.setattr(
agent, "_close_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
sentinel = SimpleNamespace(ok=True)
def fake_slow_first_event(api_kwargs, client=None, on_first_delta=None):
# Backend is alive but slow to admit: first event lands after ~2s,
# well under the 120s default cutoff. Mark the first byte so the
# no-byte detector sees activity, then return the response.
time.sleep(2.0)
agent._codex_stream_last_event_ts = time.time()
return sentinel
monkeypatch.setattr(agent, "_run_codex_stream", fake_slow_first_event)
resp = h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": "hi"})
assert resp is sentinel
assert "codex_ttfb_kill" not in closes
def test_ttfb_includes_silent_hang_hint_for_gpt_5_5(tmp_path, monkeypatch):
@ -149,7 +67,7 @@ def test_ttfb_includes_silent_hang_hint_for_gpt_5_5(tmp_path, monkeypatch):
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "1")
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "0.4")
closes: list = []
statuses: list[str] = []
@ -190,47 +108,6 @@ def test_ttfb_includes_silent_hang_hint_for_gpt_5_5(tmp_path, monkeypatch):
stop["flag"] = True
def test_ttfb_high_env_is_capped_for_openai_codex(tmp_path, monkeypatch):
"""A stale local env value like 90s must not make openai-codex wait 90s
before reconnecting when the backend emits no SSE frames."""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "90")
monkeypatch.setenv("HERMES_CODEX_TTFB_MAX_SECONDS", "1")
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
monkeypatch.setattr(
agent, "_close_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
stop = {"flag": False}
def fake_hang(api_kwargs, client=None, on_first_delta=None):
deadline = time.time() + 30
while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested:
time.sleep(0.02)
raise RuntimeError("connection closed")
monkeypatch.setattr(agent, "_run_codex_stream", fake_hang)
t0 = time.time()
try:
with pytest.raises(TimeoutError) as excinfo:
h.interruptible_api_call(agent, {"model": "gpt-5.4", "input": "hi"})
elapsed = time.time() - t0
assert "TTFB threshold: 1s" in str(excinfo.value)
assert "codex_ttfb_kill" in closes
assert elapsed < 15, f"TTFB watchdog ignored cap and took {elapsed:.1f}s"
finally:
stop["flag"] = True
def test_ttfb_does_not_kill_when_events_flow(tmp_path, monkeypatch):
@ -239,7 +116,7 @@ def test_ttfb_does_not_kill_when_events_flow(tmp_path, monkeypatch):
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "1")
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "0.4")
closes: list = []
dummy_client = SimpleNamespace()
@ -257,11 +134,11 @@ def test_ttfb_does_not_kill_when_events_flow(tmp_path, monkeypatch):
def fake_stream(api_kwargs, client=None, on_first_delta=None):
# Bytes flowing: mark stream activity right away, then keep generating
# past the 1s TTFB cutoff before returning a real response.
# past the 0.4s TTFB cutoff before returning a real response.
agent._codex_stream_last_event_ts = time.time()
if on_first_delta:
on_first_delta()
time.sleep(2.0)
time.sleep(0.9)
return sentinel
monkeypatch.setattr(agent, "_run_codex_stream", fake_stream)
@ -271,86 +148,10 @@ def test_ttfb_does_not_kill_when_events_flow(tmp_path, monkeypatch):
assert "codex_ttfb_kill" not in closes
def test_event_idle_kills_after_first_event_then_silence(tmp_path, monkeypatch):
"""If Codex emits an opening SSE event and then goes silent, kill it via
the stream-idle watchdog instead of waiting for the long non-stream stale
timeout."""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "10")
monkeypatch.setenv("HERMES_CODEX_EVENT_STALE_TIMEOUT_SECONDS", "1")
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent,
"_abort_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
monkeypatch.setattr(
agent,
"_close_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
stop = {"flag": False}
def fake_stream(api_kwargs, client=None, on_first_delta=None):
agent._codex_stream_last_event_ts = time.time()
deadline = time.time() + 30
while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested:
time.sleep(0.02)
raise RuntimeError("connection closed")
monkeypatch.setattr(agent, "_run_codex_stream", fake_stream)
try:
with pytest.raises(TimeoutError) as excinfo:
h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": "hi"})
assert "after first byte" in str(excinfo.value)
assert "codex_stream_idle_kill" in closes
assert "codex_ttfb_kill" not in closes
finally:
stop["flag"] = True
def test_wait_notice_handles_infinite_local_stale_timeout():
"""After the first SSE event, a local endpoint's infinite wall-clock
timeout must not reach ``int()``; report the finite idle watchdog instead."""
from agent import chat_completion_helpers as h
recovery = h._codex_wait_notice_recovery(
stale_timeout=float("inf"),
ttfb_enabled=True,
ttfb_timeout=120.0,
last_event_ts=130.0,
call_start=100.0,
idle_enabled=True,
idle_timeout=60.0,
elapsed=30.0,
)
assert recovery == "; auto-reconnect at 90s"
def test_wait_notice_reports_ttfb_before_first_event():
"""Before the first SSE event, the finite TTFB cutoff is the recovery."""
from agent import chat_completion_helpers as h
recovery = h._codex_wait_notice_recovery(
stale_timeout=float("inf"),
ttfb_enabled=True,
ttfb_timeout=120.0,
last_event_ts=None,
call_start=100.0,
idle_enabled=True,
idle_timeout=60.0,
elapsed=30.0,
)
assert recovery == "; auto-reconnect at 120s"
@pytest.mark.parametrize(
@ -377,40 +178,8 @@ def test_wait_notice_omits_reconnect_when_all_deadlines_are_non_finite(
assert recovery == ""
def test_wait_notice_omits_elapsed_idle_deadline():
"""An idle watchdog that already expired must not claim future recovery."""
from agent import chat_completion_helpers as h
recovery = h._codex_wait_notice_recovery(
stale_timeout=float("inf"),
ttfb_enabled=True,
ttfb_timeout=120.0,
last_event_ts=100.0,
call_start=100.0,
idle_enabled=True,
idle_timeout=30.0,
elapsed=60.0,
)
assert recovery == ""
def test_wait_notice_does_not_skip_elapsed_stale_deadline_for_later_idle():
"""An already-due watchdog wins; do not advertise a later deadline."""
from agent import chat_completion_helpers as h
recovery = h._codex_wait_notice_recovery(
stale_timeout=30.0,
ttfb_enabled=True,
ttfb_timeout=120.0,
last_event_ts=130.0,
call_start=100.0,
idle_enabled=True,
idle_timeout=60.0,
elapsed=60.0,
)
assert recovery == ""
def test_moa_heartbeat_survives_infinite_stale_timeout(monkeypatch):
@ -516,152 +285,12 @@ def test_wait_notice_formatting_error_does_not_abort_request(monkeypatch):
assert result is response
def test_ttfb_disabled_via_env_zero(tmp_path, monkeypatch):
"""Setting HERMES_CODEX_TTFB_TIMEOUT_SECONDS=0 disables the TTFB watchdog;
a no-event stall then falls through to the (here, 60s) stale timeout, so a
short hang is NOT killed by TTFB."""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "0")
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
monkeypatch.setattr(
agent, "_close_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
sentinel = SimpleNamespace(ok=True)
def fake_stream(api_kwargs, client=None, on_first_delta=None):
# No event marker, but only briefly — well under the 60s stale timeout.
time.sleep(2.0)
return sentinel
monkeypatch.setattr(agent, "_run_codex_stream", fake_stream)
resp = h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": "hi"})
assert resp is sentinel
assert "codex_ttfb_kill" not in closes
def test_large_codex_request_waits_instead_of_ttfb_reconnect(tmp_path, monkeypatch):
"""Large Codex inputs can legitimately take longer than the small-request
first-byte cutoff before the first SSE frame. Scale the TTFB timeout up
for those requests instead of killing/retrying at the small-request cutoff."""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "1")
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client", lambda c, reason=None: closes.append(reason)
)
monkeypatch.setattr(
agent, "_close_request_openai_client", lambda c, reason=None: closes.append(reason)
)
sentinel = SimpleNamespace(ok=True)
def fake_stream(api_kwargs, client=None, on_first_delta=None):
# No event marker for 2s: this would trip the 1s TTFB watchdog on a
# small request, but should be allowed for a large request.
time.sleep(2.0)
return sentinel
monkeypatch.setattr(agent, "_run_codex_stream", fake_stream)
large_input = "x" * 44_000 # ~11k estimated tokens, above the 10k gate.
resp = h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": large_input})
assert resp is sentinel
assert "codex_ttfb_kill" not in closes
def test_large_codex_request_can_still_ttfb_reconnect_when_capped(tmp_path, monkeypatch):
"""Large Codex requests should keep a finite TTFB watchdog instead of
disabling it entirely. A low max cap should still force an early reconnect."""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "1")
monkeypatch.setenv("HERMES_CODEX_TTFB_MAX_SECONDS", "1")
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client", lambda c, reason=None: closes.append(reason)
)
monkeypatch.setattr(
agent, "_close_request_openai_client", lambda c, reason=None: closes.append(reason)
)
stop = {"flag": False}
def fake_hang(api_kwargs, client=None, on_first_delta=None):
deadline = time.time() + 30
while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested:
time.sleep(0.02)
raise RuntimeError("connection closed")
monkeypatch.setattr(agent, "_run_codex_stream", fake_hang)
large_input = "x" * 44_000 # ~11k estimated tokens, above the large-request gate.
try:
with pytest.raises(TimeoutError) as excinfo:
h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": large_input})
assert "TTFB threshold: 1s" in str(excinfo.value)
assert "codex_ttfb_kill" in closes
finally:
stop["flag"] = True
def test_large_codex_request_strict_ttfb_env_still_reconnects(tmp_path, monkeypatch):
"""Operators can force the old early-reconnect behavior for large inputs
with HERMES_CODEX_TTFB_STRICT=1."""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "1")
monkeypatch.setenv("HERMES_CODEX_TTFB_STRICT", "1")
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client", lambda c, reason=None: closes.append(reason)
)
monkeypatch.setattr(
agent, "_close_request_openai_client", lambda c, reason=None: closes.append(reason)
)
stop = {"flag": False}
def fake_hang(api_kwargs, client=None, on_first_delta=None):
deadline = time.time() + 30
while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested:
time.sleep(0.02)
raise RuntimeError("connection closed")
monkeypatch.setattr(agent, "_run_codex_stream", fake_hang)
large_input = "x" * 44_000
try:
with pytest.raises(TimeoutError) as excinfo:
h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": large_input})
assert "TTFB threshold: 1s" in str(excinfo.value)
assert "codex_ttfb_kill" in closes
finally:
stop["flag"] = True
def test_large_codex_request_hard_ceiling_reclaims_silent_stall(tmp_path, monkeypatch):
@ -718,87 +347,5 @@ def test_large_codex_request_hard_ceiling_reclaims_silent_stall(tmp_path, monkey
stop["flag"] = True
def test_large_codex_request_hard_ceiling_disabled_restores_legacy(tmp_path, monkeypatch):
"""Setting HERMES_CODEX_HARD_TIMEOUT_SECONDS=0 disables the ceiling entirely,
restoring the pre-#64507 behavior (request waits out the raised stale floor
instead of being capped). Keeps the knob for operators who must.
"""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_HARD_TIMEOUT_SECONDS", "0")
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
monkeypatch.setattr(
agent, "_close_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
sentinel = SimpleNamespace(ok=True)
def fake_stream(api_kwargs, client=None, on_first_delta=None):
# No event, but only briefly — well under the (here 60s) stale timeout.
time.sleep(2.0)
return sentinel
monkeypatch.setattr(agent, "_run_codex_stream", fake_stream)
large_input = "x" * 44_000
resp = h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": large_input})
assert resp is sentinel
assert "codex_ttfb_kill" not in closes
assert "stale_call_kill" not in closes
def test_large_codex_request_hard_ceiling_caps_raised_stale_floor(tmp_path, monkeypatch):
"""The hard ceiling must cap the raised stale floor (openai-codex can push
the stale timeout to 1200s at >100k tokens). A large silent stall must die
at the ceiling, proving the min() wins over the floor.
"""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_HARD_TIMEOUT_SECONDS", "4")
# Force the >100k-token tier so openai_codex_stale_timeout_floor returns 1200s.
monkeypatch.setattr(
agent, "_compute_non_stream_stale_timeout", lambda *a, **k: 1200.0
)
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
monkeypatch.setattr(
agent, "_close_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
stop = {"flag": False}
def fake_hang(api_kwargs, client=None, on_first_delta=None):
deadline = time.time() + 200
while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested:
time.sleep(0.02)
raise RuntimeError("connection closed")
monkeypatch.setattr(agent, "_run_codex_stream", fake_hang)
huge_input = "x" * 500_000 # ~125k tokens → stale floor 1200s
t0 = time.time()
try:
with pytest.raises(TimeoutError):
h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": huge_input})
elapsed = time.time() - t0
assert elapsed < 40, f"hard ceiling lost to stale floor: {elapsed:.1f}s"
assert "stale_call_kill" in closes, f"stale kill expected, got {closes}"
finally:
stop["flag"] = True

View file

@ -38,20 +38,8 @@ def _git_init(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_bare_git_repo_without_code_stays_general(self, tmp_path):
# A git repo of only prose (notes/writing/research — a big non-coding use
@ -70,11 +58,6 @@ class TestIsCodingContext:
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
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.
@ -102,30 +85,9 @@ class TestCodingSelection:
# …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 ─────────────────────────────────────────────────────
@ -154,40 +116,9 @@ class TestWorkspaceBlock:
# ── 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_worktree_detected_without_primary_path(self, tmp_path):
# A linked worktree should be detected, but the output must NOT contain
@ -212,21 +143,7 @@ class TestProjectFacts:
# The worktree root IS the reported root.
assert f"Root: {worktree.resolve()}" in block or "Root:" 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
def test_detect_project_facts_structured(self, tmp_path):
(tmp_path / "package.json").write_text(
@ -254,8 +171,6 @@ class TestProjectFacts:
for cmd in facts["verifyCommands"]:
assert cmd in verify_line
def test_project_facts_for_none_outside_workspace(self, tmp_path):
assert cc.project_facts_for(tmp_path) is None
# ── $HOME dotfiles guard ────────────────────────────────────────────────────
@ -273,13 +188,6 @@ class TestHomeDotfilesGuard:
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"
@ -292,12 +200,6 @@ class TestHomeDotfilesGuard:
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 ─────────────────────────────────────────────
@ -341,61 +243,15 @@ class TestRuntimeMode:
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_coding_instructions_append_their_own_block(self, tmp_path):
_git_init(tmp_path)
cfg = {
"agent": {
"coding_context": "on",
"coding_instructions": "Clean the diff before commit.",
}
}
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config=cfg)
blocks = mode.system_blocks()
# The brief stays block 0 (byte-stable, cache-keyed independently); the
# operator instructions ride a separate trailing block.
assert blocks[0] == cc.CODING_AGENT_GUIDANCE
assert any("Clean the diff before commit." in b for b in blocks[1:])
def test_coding_instructions_accept_a_list(self, tmp_path):
_git_init(tmp_path)
cfg = {
"agent": {
"coding_context": "on",
"coding_instructions": ["No tsc/lint on UI.", "Clean the diff."],
}
}
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config=cfg)
instr_block = mode.system_blocks()[-1]
assert "No tsc/lint on UI." in instr_block
assert "Clean the diff." in instr_block
def test_no_instructions_block_when_unset(self, tmp_path):
_git_init(tmp_path)
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "on"}})
assert not any("Operator instructions" in b for b in mode.system_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) ──────────────────────────
@ -433,51 +289,15 @@ class TestEditFormatSteering:
assert "single-file" in brief
assert "mode='replace'" not in brief
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.

View file

@ -127,31 +127,6 @@ class TestFutilityGuard:
)
assert fired <= 3, f"expected the loop to break early, compacted {fired}x"
def test_rough_preflight_reading_does_not_reopen_the_loop(self):
"""should_compress() runs twice per turn with two different measures.
The pre-API gate uses a rough estimate that can dip below the threshold;
the post-response gate uses the real count that does not. If the verdict
lived in should_compress(), the rough reading would reset the strike
every turn and the loop would never stop. Judging it in
update_from_response() (real-vs-real) closes that hole.
"""
cc = _compressor(threshold_tokens=24_576)
msgs = _messages(13)
rough, real = 20_000, 33_564 # rough dips under; real never does
fired = 0
for _ in range(8):
cc.should_compress(rough) # pre-API gate (rough)
msgs, did = _turn(cc, msgs, real) # post-response gate (real) + usage
if did:
fired += 1
msgs.append({"role": "user", "content": "more " + "w" * 3000})
assert fired <= 2, (
f"a sub-threshold rough reading must not re-open the loop; "
f"compacted {fired}x"
)
def test_effective_compaction_still_resets_the_counter(self):
"""A compaction that gets the prompt under the threshold is not thrashing."""
@ -190,61 +165,10 @@ class TestFutilityGuard:
"tokenizer skew must not be mistaken for an incompressible floor"
)
def test_latched_counter_resets_after_any_real_prompt_fits(self):
cc = _compressor(threshold_tokens=24_576)
cc._ineffective_compression_count = 2
cc.update_from_response({"prompt_tokens": 20_000})
assert cc._ineffective_compression_count == 0
assert cc.should_compress(33_564)
def test_usage_less_response_consumes_pending_verdict(self):
cc = _compressor(threshold_tokens=24_576)
cc._verify_compaction_cleared_threshold = True
cc.awaiting_real_usage_after_compression = True
cc.update_from_response({})
assert cc._verify_compaction_cleared_threshold is False
assert cc.awaiting_real_usage_after_compression is False
assert cc._ineffective_compression_count == 0
def test_fallback_streak_survives_ordinary_fitting_responses(self):
cc = _compressor(threshold_tokens=24_576)
cc.record_completed_compaction(used_fallback=True)
cc.update_from_response({"prompt_tokens": 20_000})
assert cc._fallback_compression_streak == 1
# Context regrows through ordinary successful turns before the next
# fallback boundary. Those turns reset real-usage effectiveness, not
# the independent summary-quality breaker.
cc.update_from_response({"prompt_tokens": 20_000})
cc.record_completed_compaction(used_fallback=True)
cc.update_from_response({"prompt_tokens": 20_000})
assert cc._fallback_compression_streak == 2
assert not cc.should_compress(33_564)
def test_usage_less_fallback_boundary_still_counts(self):
cc = _compressor(threshold_tokens=24_576)
cc.record_completed_compaction(used_fallback=True)
cc.awaiting_real_usage_after_compression = True
cc.update_from_response({})
assert cc._fallback_compression_streak == 1
assert cc._verify_compaction_cleared_threshold is False
assert cc.awaiting_real_usage_after_compression is False
def test_healthy_boundary_resets_only_fallback_streak(self):
cc = _compressor(threshold_tokens=24_576)
cc.record_completed_compaction(used_fallback=True)
cc.record_completed_compaction(used_fallback=False)
assert cc._fallback_compression_streak == 0
assert cc._verify_compaction_cleared_threshold is True
def test_model_switch_resets_and_persists_fallback_streak(self, tmp_path):
from hermes_state import SessionDB
@ -260,41 +184,7 @@ class TestFutilityGuard:
assert cc._fallback_compression_streak == 0
assert db.get_compression_fallback_streak("s1") == 0
def test_same_runtime_context_recalibration_preserves_fallback_streak(self, tmp_path):
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("s1", source="cli")
cc = _compressor(threshold_tokens=24_576)
cc.bind_session_state(db, "s1")
cc.record_completed_compaction(used_fallback=True)
cc.update_model(cc.model, 64_000, provider=cc.provider)
assert cc._fallback_compression_streak == 1
assert db.get_compression_fallback_streak("s1") == 1
def test_a_failed_pass_records_exactly_one_strike(self):
"""A compaction that leaves the real prompt over the threshold: one strike.
The verdict is judged once, when the provider reports real usage not on
every should_compress() reading.
"""
cc = _compressor(threshold_tokens=24_576)
msgs = _messages(14)
assert cc.should_compress(33_564)
cc.compress(msgs, current_tokens=33_564)
cc._verify_compaction_cleared_threshold = True
assert cc._ineffective_compression_count == 0, "no verdict before real usage"
cc.update_from_response({"prompt_tokens": 33_564}) # still over
assert cc._ineffective_compression_count == 1
# A later reading, rough or real, must not add phantom strikes.
cc.should_compress(33_564)
cc.should_compress(20_000)
assert cc._ineffective_compression_count == 1
class TestMinimumMessagesBranch:

View file

@ -87,89 +87,7 @@ def test_no_focus_topic_no_injection():
assert "FOCUS TOPIC" not in prompt_text
def test_compress_passes_focus_to_generate_summary():
"""compress() passes focus_topic through to _generate_summary."""
compressor = _make_compressor()
# Track what _generate_summary receives
received_kwargs = {}
original_generate = compressor._generate_summary
def tracking_generate(turns, **kwargs):
received_kwargs.update(kwargs)
return "## Goal\nTest."
compressor._generate_summary = tracking_generate
messages = [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply1"},
{"role": "user", "content": "second"},
{"role": "assistant", "content": "reply2"},
{"role": "user", "content": "third"},
{"role": "assistant", "content": "reply3"},
{"role": "user", "content": "fourth"},
{"role": "assistant", "content": "reply4"},
]
compressor.compress(messages, current_tokens=100000, focus_topic="authentication flow")
assert received_kwargs.get("focus_topic") == "authentication flow"
def test_compress_none_focus_by_default():
"""Auto compression derives focus_topic from recent user turns by default."""
compressor = _make_compressor()
received_kwargs = {}
def tracking_generate(turns, **kwargs):
received_kwargs.update(kwargs)
return "## Goal\nTest."
compressor._generate_summary = tracking_generate
messages = [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply1"},
{"role": "user", "content": "second"},
{"role": "assistant", "content": "reply2"},
{"role": "user", "content": "third"},
{"role": "assistant", "content": "reply3"},
{"role": "user", "content": "fourth"},
{"role": "assistant", "content": "reply4"},
]
compressor.compress(messages, current_tokens=100000)
focus_topic = received_kwargs.get("focus_topic")
assert focus_topic.startswith("Recent user focus:")
assert "- second" in focus_topic
assert "- third" in focus_topic
assert "- fourth" in focus_topic
def test_auto_focus_skips_context_summary_handoff():
"""Persisted handoff messages should not become the inferred focus."""
compressor = _make_compressor()
messages = [
{"role": "system", "content": "System prompt"},
{
"role": "user",
"content": "[CONTEXT COMPACTION — REFERENCE ONLY] stale Bybit topic",
},
{"role": "assistant", "content": "handoff acknowledged"},
{"role": "user", "content": "Can OpenViking support sqlite backends?"},
{"role": "assistant", "content": "Let's inspect that."},
{"role": "user", "content": "Compare OpenViking postgres and sqlite options."},
{"role": "assistant", "content": "Working on it."},
{"role": "user", "content": "Now focus on OpenViking database support."},
{"role": "assistant", "content": "Latest tail response"},
]
focus_topic = compressor._derive_auto_focus_topic(messages)
assert "OpenViking" in focus_topic
assert "Bybit" not in focus_topic

View file

@ -112,19 +112,6 @@ class TestClassifySummaryContent:
assert ContextCompressor.classify_summary_content(content) == "standalone"
assert ContextCompressor._is_context_summary_content(content) is True
def test_legacy_and_historical_prefixes_are_standalone(self):
from agent.context_compressor import (
LEGACY_SUMMARY_PREFIX,
_HISTORICAL_SUMMARY_PREFIXES,
)
assert ContextCompressor.classify_summary_content(
LEGACY_SUMMARY_PREFIX + " body"
) == "standalone"
for prefix in _HISTORICAL_SUMMARY_PREFIXES:
assert ContextCompressor.classify_summary_content(
prefix + " body"
) == "standalone"
def test_merged_tail_summary(self):
from agent.context_compressor import (
@ -144,19 +131,7 @@ class TestClassifySummaryContent:
assert ContextCompressor.classify_summary_content(merged) == "merged"
assert ContextCompressor._is_context_summary_content(merged) is True
def test_plain_messages_classify_none(self):
assert ContextCompressor.classify_summary_content("just a question") is None
assert ContextCompressor.classify_summary_content("") is None
assert ContextCompressor.classify_summary_content(None) is None
def test_delimiter_without_summary_prefix_is_none(self):
"""A message merely quoting the merged delimiter (e.g. a user pasting
logs) is not a summary unless a handoff prefix follows it."""
from agent.context_compressor import _MERGED_SUMMARY_DELIMITER
content = "look at this:\n" + _MERGED_SUMMARY_DELIMITER + "\nnot a summary"
assert ContextCompressor.classify_summary_content(content) is None
assert ContextCompressor._is_context_summary_content(content) is False
class TestClassifyAgreesWithPredicatesOnLiveEmissions:

View file

@ -71,25 +71,6 @@ class TestCounterRoundTripsBindSessionState:
"tripped anti-thrash guard instead of re-compacting"
)
def test_fresh_compressor_inherits_armed_single_strike(self, tmp_path):
"""One strike before the restart still counts toward the trip."""
db = _db(tmp_path)
db.create_session("s1", source="cli")
first = _compressor(db, "s1")
first._verify_compaction_cleared_threshold = True
first.update_from_response({"prompt_tokens": first.threshold_tokens + 1})
assert first._ineffective_compression_count == 1
second = _compressor(db, "s1")
assert second._ineffective_compression_count == 1
# One inherited strike does not block yet...
assert second.should_compress(10**9) is True
# ...but the next ineffective pass trips the guard cross-process.
second._verify_compaction_cleared_threshold = True
second.update_from_response({"prompt_tokens": second.threshold_tokens + 1})
assert second._ineffective_compression_count == 2
assert second.should_compress(10**9) is False
def test_rebind_to_other_session_does_not_leak_counter(self, tmp_path):
"""The counter is per-session: switching sessions must not carry it."""
@ -104,14 +85,6 @@ class TestCounterRoundTripsBindSessionState:
cc.bind_session_state(db, "cold")
assert cc._ineffective_compression_count == 0
def test_unbound_compressor_keeps_in_memory_behavior(self):
"""No session DB bound (plugins/tests): everything still works."""
cc = _compressor()
cc._verify_compaction_cleared_threshold = True
cc.update_from_response({"prompt_tokens": cc.threshold_tokens + 1})
assert cc._ineffective_compression_count == 1
cc.update_from_response({"prompt_tokens": 1})
assert cc._ineffective_compression_count == 0
class TestResetSemanticsPreserved:

View file

@ -51,58 +51,7 @@ def _trip(cc: ContextCompressor) -> None:
class TestRecoveryWindow:
def test_blocked_within_window_unblocked_after(self):
cc = _compressor()
_trip(cc)
base = 1000.0
with patch("agent.context_compressor.time.monotonic", return_value=base):
# First blocked evaluation arms the clock and stays blocked.
assert cc.should_compress(cc.threshold_tokens + 1) is False
with patch(
"agent.context_compressor.time.monotonic",
return_value=base + cc._ANTI_THRASH_RECOVERY_SECONDS - 1,
):
# Still inside the window: protection intact.
assert cc.should_compress(cc.threshold_tokens + 1) is False
assert cc._ineffective_compression_count == 2
with patch(
"agent.context_compressor.time.monotonic",
return_value=base + cc._ANTI_THRASH_RECOVERY_SECONDS + 1,
):
# Window elapsed: exactly one probe is granted.
assert cc.should_compress(cc.threshold_tokens + 1) is True
# Probation, not amnesty: one strike remains armed.
assert cc._ineffective_compression_count == 1
def test_ineffective_probe_re_trips_and_waits_a_full_fresh_window(self):
cc = _compressor()
_trip(cc)
base = 1000.0
with patch("agent.context_compressor.time.monotonic", return_value=base):
assert cc.should_compress(cc.threshold_tokens + 1) is False
probe_time = base + cc._ANTI_THRASH_RECOVERY_SECONDS + 1
with patch(
"agent.context_compressor.time.monotonic", return_value=probe_time
):
assert cc.should_compress(cc.threshold_tokens + 1) is True
# The probe compaction completes but does not clear the threshold.
cc._verify_compaction_cleared_threshold = True
cc.update_from_response({"prompt_tokens": cc.threshold_tokens + 1})
assert cc._ineffective_compression_count == 2
# Re-tripped: blocked again immediately (arms a new clock).
assert cc.should_compress(cc.threshold_tokens + 1) is False
with patch(
"agent.context_compressor.time.monotonic",
return_value=probe_time + cc._ANTI_THRASH_RECOVERY_SECONDS - 5,
):
# No immediate re-probe loop: the second window is full length,
# measured from the re-trip, not the original trip.
assert cc.should_compress(cc.threshold_tokens + 1) is False
with patch(
"agent.context_compressor.time.monotonic",
return_value=probe_time + cc._ANTI_THRASH_RECOVERY_SECONDS + 5,
):
assert cc.should_compress(cc.threshold_tokens + 1) is True
def test_effective_probe_clears_the_guard_completely(self):
cc = _compressor()
@ -133,28 +82,7 @@ class TestRecoveryWindow:
assert cc.should_compress(cc.threshold_tokens + 1) is True
assert cc._fallback_compression_streak == 1
def test_under_threshold_never_arms_the_clock(self):
cc = _compressor()
_trip(cc)
base = 1000.0
with patch("agent.context_compressor.time.monotonic", return_value=base):
# Under threshold: gate never evaluated, clock untouched.
assert cc.should_compress(cc.threshold_tokens - 1) is False
assert cc._anti_thrash_recovery_deadline == 0.0
def test_untripped_guard_disarms_a_stale_clock(self):
cc = _compressor()
_trip(cc)
base = 1000.0
with patch("agent.context_compressor.time.monotonic", return_value=base):
assert cc.should_compress(cc.threshold_tokens + 1) is False
assert cc._anti_thrash_recovery_deadline > 0.0
# A fitting real-usage reading clears the counter mid-window.
cc.update_from_response({"prompt_tokens": cc.threshold_tokens - 500})
with patch("agent.context_compressor.time.monotonic", return_value=base + 1):
assert cc.should_compress(cc.threshold_tokens + 1) is True
# The stale clock was disarmed, so a LATER trip starts a full window.
assert cc._anti_thrash_recovery_deadline == 0.0
class TestRestartSemantics:

View file

@ -63,7 +63,7 @@ def _build_agent_with_db(db: SessionDB, session_id: str):
compressor = MagicMock()
def _compress_with_overlap(*_a, **_kw):
time.sleep(0.25)
time.sleep(0.15)
return [
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
{"role": "user", "content": "tail"},
@ -118,136 +118,14 @@ def _wait_for_touch(touch_calls: list[str], value: str, timeout: float = 1.0) ->
pytest.fail(f"Timed out waiting for touch activity {value!r}; calls={touch_calls!r}")
def test_compression_activity_heartbeat_touches_agent_during_long_compress(tmp_path: Path) -> None:
"""Long compression must refresh agent activity so gateway watchdogs do not fire."""
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HEARTBEAT_TEST"
db.create_session(session_id, source="test")
agent = _build_agent_with_db(db, session_id)
agent._compression_activity_heartbeat_interval = 0.1
touch_calls: list[str] = []
agent._touch_activity = lambda desc: touch_calls.append(desc)
def _slow_compress(*_a, **_kw):
_wait_for_touch(touch_calls, "context compression in progress")
return [
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
{"role": "user", "content": "tail"},
]
agent.context_compressor.compress.side_effect = _slow_compress
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
agent._compress_context(messages, "sys", approx_tokens=120_000)
assert touch_calls[0] == "context compression started"
assert "context compression in progress" in touch_calls
assert touch_calls[-1] == "context compression completed"
assert db.get_compression_lock_holder(session_id) is None
def test_compression_activity_heartbeat_stops_on_compress_exception(tmp_path: Path) -> None:
"""Exception paths must stop the heartbeat and release the compression lock."""
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HEARTBEAT_FAIL_TEST"
db.create_session(session_id, source="test")
agent = _build_agent_with_db(db, session_id)
agent._compression_activity_heartbeat_interval = 0.1
touch_calls: list[str] = []
agent._touch_activity = lambda desc: touch_calls.append(desc)
def _failing_compress(*_a, **_kw):
_wait_for_touch(touch_calls, "context compression in progress")
raise RuntimeError("compress boom")
agent.context_compressor.compress.side_effect = _failing_compress
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
with pytest.raises(RuntimeError, match="compress boom"):
agent._compress_context(messages, "sys", approx_tokens=120_000)
assert touch_calls[0] == "context compression started"
assert "context compression in progress" in touch_calls
assert touch_calls[-1] == "context compression failed"
assert db.get_compression_lock_holder(session_id) is None
def test_compression_activity_heartbeat_ignores_touch_errors(tmp_path: Path) -> None:
"""Activity touch failures must not affect compression success semantics."""
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HEARTBEAT_TOUCH_ERROR_TEST"
db.create_session(session_id, source="test")
agent = _build_agent_with_db(db, session_id)
agent._compression_activity_heartbeat_interval = 0.1
agent._touch_activity = lambda _desc: (_ for _ in ()).throw(RuntimeError("touch boom"))
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
assert compressed[0]["content"] == "[CONTEXT COMPACTION] summary"
assert db.get_compression_lock_holder(session_id) is None
def test_compression_activity_heartbeat_strict_signature_fallback_releases_lock(tmp_path: Path) -> None:
"""Strict compressor signatures still compress while heartbeat cleanup runs.
Main inspects the engine signature up front (_supported_compression_kwargs)
instead of catching TypeError, so a strict-signature engine is invoked
exactly once with only the kwargs it accepts. The heartbeat (with a
non-numeric configured interval falling back to the default) must still
wrap the call and stop cleanly.
"""
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HEARTBEAT_TYPEERROR_TEST"
db.create_session(session_id, source="test")
agent = _build_agent_with_db(db, session_id)
agent._compression_activity_heartbeat_interval = "not-a-number"
touch_calls: list[str] = []
agent._touch_activity = lambda desc: touch_calls.append(desc)
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
strict_calls: list[int | None] = []
def _strict_compress(messages, current_tokens=None):
strict_calls.append(current_tokens)
return [
{"role": "user", "content": "[CONTEXT COMPACTION] strict summary"},
{"role": "user", "content": "tail"},
]
agent.context_compressor.compress = _strict_compress
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
assert compressed[0]["content"] == "[CONTEXT COMPACTION] strict summary"
assert touch_calls[0] == "context compression started"
assert touch_calls[-1] == "context compression completed"
assert db.get_compression_lock_holder(session_id) is None
assert strict_calls == [120_000]
def test_compression_activity_heartbeat_nonfinite_interval_falls_back(tmp_path: Path) -> None:
"""Non-finite heartbeat intervals must not reach Event.wait()."""
from agent.conversation_compression import _CompressionActivityHeartbeat
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HEARTBEAT_NONFINITE_INTERVAL_TEST"
db.create_session(session_id, source="test")
agent = _build_agent_with_db(db, session_id)
touch_calls: list[str] = []
agent._touch_activity = lambda desc: touch_calls.append(desc)
heartbeat = _CompressionActivityHeartbeat(agent, interval_seconds=float("inf"))
assert heartbeat._interval_seconds == 60.0
heartbeat.start()
heartbeat.stop()
assert touch_calls == ["context compression started", "context compression completed"]
@ -381,95 +259,8 @@ def test_durable_message_committed_before_lease_is_adopted(
assert child_id is not None
assert child_id == agent.session_id
def test_skipped_compression_returns_messages_unchanged(tmp_path: Path) -> None:
"""The loser of the lock race must return its input messages verbatim.
Callers (preflight compression in ``conversation_loop.py``) detect the
no-op via ``len(returned) == len(input)`` and stop the auto-compress
retry loop. If the skipped path returned the compressed view, that
detection would break and the caller would mutate the conversation
without going through state.db rotation.
"""
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "LOSER_TEST"
db.create_session(parent_sid, source="discord")
# Pre-acquire the lock so the agent's compress_context sees it held.
held = db.try_acquire_compression_lock(parent_sid, "external_holder")
assert held is True
agent = _build_agent_with_db(db, parent_sid)
messages = [{"role": "user", "content": "m1"}, {"role": "user", "content": "m2"}]
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
# Skipped: messages returned verbatim, no rotation
assert compressed is messages or compressed == messages
assert agent.session_id == parent_sid
# Compressor was never called (the skip happens before .compress())
agent.context_compressor.compress.assert_not_called()
def test_cancelled_commit_fence_blocks_late_session_db_compaction(
tmp_path: Path,
) -> None:
"""A worker cancelled during summarization must not mutate SessionDB later."""
from agent.conversation_compression import CompressionCommitFence
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "HYGIENE_TIMEOUT_SESSION"
db.create_session(session_id, source="telegram")
agent = _build_agent_with_db(db, session_id)
agent.compression_in_place = True
agent._cached_system_prompt = "sys"
agent._last_compaction_in_place = True
summary_started = threading.Event()
release_summary = threading.Event()
def _slow_summary(*_args, **_kwargs):
summary_started.set()
assert release_summary.wait(timeout=5)
return [
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
{"role": "user", "content": "tail"},
]
agent.context_compressor.compress.side_effect = _slow_summary
archive_spy = MagicMock(wraps=db.archive_and_compact)
db.archive_and_compact = archive_spy
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
fence = CompressionCommitFence()
result = {}
errors = []
def _run_compression() -> None:
try:
result["value"] = agent._compress_context(
messages,
"sys",
approx_tokens=120_000,
commit_fence=fence,
)
except BaseException as exc: # pragma: no cover - surfaced below
errors.append(exc)
worker = threading.Thread(target=_run_compression, name="timed-out-hygiene")
worker.start()
assert summary_started.wait(timeout=2)
assert fence.cancel_before_commit() is True
release_summary.set()
worker.join(timeout=5)
assert not worker.is_alive()
assert errors == []
compressed, _prompt = result["value"]
assert compressed is messages
assert agent.session_id == session_id
assert agent._last_compaction_in_place is False
archive_spy.assert_not_called()
assert db.get_compression_lock_holder(session_id) is None
def test_fence_cancelled_compression_leaves_lock_reacquirable(tmp_path: Path) -> None:
@ -611,87 +402,10 @@ def test_delayed_contender_adopts_unique_rotated_child(tmp_path: Path) -> None:
assert lifecycle_kwargs["session_db"] is db
def test_delayed_contender_fails_closed_without_unique_child(tmp_path: Path) -> None:
"""Missing or ambiguous lineage must not silently select a continuation."""
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "AMBIGUOUS_PARENT"
db.create_session(parent_sid, source="webui")
db.end_session(parent_sid, "compression")
db.create_session("CHILD_A", source="webui", parent_session_id=parent_sid)
db.create_session("CHILD_B", source="webui", parent_session_id=parent_sid)
db.replace_messages("CHILD_A", [{"role": "user", "content": "a"}])
db.replace_messages("CHILD_B", [{"role": "user", "content": "b"}])
agent = _build_agent_with_db(db, parent_sid)
stale_messages = [{"role": "user", "content": "stale"}]
returned, _system_prompt = agent._compress_context(
stale_messages, "sys", approx_tokens=120_000
)
assert returned is stale_messages or returned == stale_messages
assert agent.session_id == parent_sid
agent.context_compressor.compress.assert_not_called()
def test_compression_restores_user_turn_when_compressor_drops_all_users(tmp_path: Path) -> None:
"""Provider chat templates need at least one user message after compaction.
A plugin or future compressor can legally return a compacted context made
only of assistant/tool summary rows. Before the guard in
``compress_context``, that transcript went straight into the next API call;
LM Studio / llama.cpp Jinja templates then failed with "No user query found
in messages." Preserve the last real user turn from the pre-compression
transcript instead of inventing a new active request.
"""
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "NO_USER_AFTER_COMPRESS"
db.create_session(parent_sid, source="cli")
agent = _build_agent_with_db(db, parent_sid)
agent.context_compressor.compress.side_effect = lambda *_a, **_kw: [
{
"role": "assistant",
"content": "[CONTEXT COMPACTION] earlier work was summarized",
}
]
messages = [
{"role": "user", "content": "first request"},
{"role": "assistant", "content": "first answer"},
{"role": "user", "content": "please continue from here"},
{"role": "assistant", "content": "working"},
]
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
user_messages = [msg for msg in compressed if msg.get("role") == "user"]
assert user_messages == [{"role": "user", "content": "please continue from here"}]
def test_synthetic_user_scaffolding_does_not_replace_human_anchor(tmp_path: Path) -> None:
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "SYNTHETIC_USER_AFTER_COMPRESS"
db.create_session(parent_sid, source="cli")
agent = _build_agent_with_db(db, parent_sid)
agent.context_compressor.compress.side_effect = lambda *_a, **_kw: [
{"role": "assistant", "content": "[CONTEXT COMPACTION] summary"},
{
"role": "user",
"content": "[Your active task list was preserved across context compression]",
"_todo_snapshot_synthetic": True,
},
]
messages = [
{"role": "user", "content": "the actual human objective"},
{"role": "assistant", "content": "working"},
]
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
assert any(
msg.get("role") == "user" and msg.get("content") == "the actual human objective"
for msg in compressed
)
def _no_consecutive_user_roles(messages: list) -> bool:
@ -747,22 +461,6 @@ def test_restored_anchor_never_creates_consecutive_user_roles() -> None:
assert not compressed[0].get("_todo_snapshot_synthetic")
def test_user_role_compaction_summary_is_not_a_human_anchor() -> None:
"""A summary pinned to role="user" must not satisfy the anchor check.
The compressor flips the summary message to role="user" when the tail
opens with an assistant turn; treating that summary as human intent
would skip anchor restoration entirely.
"""
from agent.context_compressor import SUMMARY_PREFIX
from agent.conversation_compression import _is_real_user_message
summary_as_user = {
"role": "user",
"content": f"{SUMMARY_PREFIX}\n## Historical Task Snapshot\nUser asked: x",
}
assert not _is_real_user_message(summary_as_user)
assert _is_real_user_message({"role": "user", "content": "please continue"})
def test_compression_persists_child_handoff_immediately(tmp_path: Path) -> None:
@ -784,21 +482,6 @@ def test_compression_persists_child_handoff_immediately(tmp_path: Path) -> None:
assert len(db.get_messages(child_sid)) == len(compressed)
def test_empty_compression_result_does_not_rotate_session(tmp_path: Path) -> None:
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "EMPTY_COMPRESS_PARENT"
db.create_session(parent_sid, source="cli")
agent = _build_agent_with_db(db, parent_sid)
agent.context_compressor.compress.side_effect = lambda *_a, **_kw: []
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
returned, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
assert returned is messages or returned == messages
assert agent.session_id == parent_sid
assert _count_children(db, parent_sid) == 0
assert db.get_session(parent_sid)["end_reason"] is None
@pytest.mark.parametrize("in_place", [False, True])
@ -837,59 +520,6 @@ def test_equal_copy_compression_result_does_not_rewrite_session(
archive_and_compact.assert_not_called()
def test_lock_refresh_keeps_owner_live_past_initial_ttl(tmp_path: Path, monkeypatch) -> None:
"""The owning compression call must keep its lease alive while it runs."""
real_try_acquire = SessionDB.try_acquire_compression_lock
def _short_ttl(self, session_id: str, holder: str, ttl_seconds: float = 300.0) -> bool:
return real_try_acquire(self, session_id, holder, ttl_seconds=1.0)
monkeypatch.setattr(SessionDB, "try_acquire_compression_lock", _short_ttl)
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "REFRESH_TEST"
db.create_session(parent_sid, source="discord")
agent_a = _build_agent_with_db(db, parent_sid)
# 3s TTL / 0.25s refresh: ~12 refresh opportunities per lease. A 1s TTL
# left one missed scheduling quantum between "refreshed" and "expired"
# on a loaded runner.
agent_a._compression_lock_ttl_seconds = 3.0
agent_a._compression_lock_refresh_interval = 0.25
compression_started = threading.Event()
release_compression = threading.Event()
def _slow_compress(*_a, **_kw):
compression_started.set()
assert release_compression.wait(timeout=10)
return [
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
{"role": "user", "content": "tail"},
]
agent_a.context_compressor.compress.side_effect = _slow_compress
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
def run(agent):
agent._compress_context(messages, "sys", approx_tokens=120_000)
t_a = threading.Thread(target=run, args=(agent_a,), name="refresh_owner")
t_a.start()
try:
assert compression_started.wait(timeout=10), "compression never acquired its lock"
assert db.get_compression_lock_holder(parent_sid) is not None
time.sleep(3.5)
assert db.try_acquire_compression_lock(
parent_sid, "refresh_probe", ttl_seconds=3.0
) is False, "live owner lease expired and was reclaimable before compression finished"
finally:
release_compression.set()
t_a.join(timeout=10)
assert not t_a.is_alive()
assert _count_children(db, parent_sid) == 1
assert db.get_compression_lock_holder(parent_sid) is None
def test_post_compress_exception_stops_lock_refresher(tmp_path: Path, monkeypatch) -> None:
@ -897,7 +527,7 @@ def test_post_compress_exception_stops_lock_refresher(tmp_path: Path, monkeypatc
real_try_acquire = SessionDB.try_acquire_compression_lock
def _short_ttl(self, session_id: str, holder: str, ttl_seconds: float = 300.0) -> bool:
return real_try_acquire(self, session_id, holder, ttl_seconds=1.0)
return real_try_acquire(self, session_id, holder, ttl_seconds=0.15)
monkeypatch.setattr(SessionDB, "try_acquire_compression_lock", _short_ttl)
@ -906,8 +536,8 @@ def test_post_compress_exception_stops_lock_refresher(tmp_path: Path, monkeypatc
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
agent._compression_lock_ttl_seconds = 1.0
agent._compression_lock_refresh_interval = 0.1
agent._compression_lock_ttl_seconds = 0.15
agent._compression_lock_refresh_interval = 0.05
agent.context_compressor._last_summary_error = "summary failed"
agent._emit_warning = lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("warn boom"))
@ -916,111 +546,14 @@ def test_post_compress_exception_stops_lock_refresher(tmp_path: Path, monkeypatc
with pytest.raises(RuntimeError, match="warn boom"):
agent._compress_context(messages, "sys", approx_tokens=120_000)
time.sleep(1.3)
time.sleep(0.25)
assert db.try_acquire_compression_lock(parent_sid, "probe", ttl_seconds=1.0) is True
def test_abort_warning_exception_stops_lock_refresher(tmp_path: Path, monkeypatch) -> None:
"""An abort-path warning exception must still release the refreshed lock."""
real_try_acquire = SessionDB.try_acquire_compression_lock
def _short_ttl(self, session_id: str, holder: str, ttl_seconds: float = 300.0) -> bool:
return real_try_acquire(self, session_id, holder, ttl_seconds=1.0)
monkeypatch.setattr(SessionDB, "try_acquire_compression_lock", _short_ttl)
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "REFRESH_ABORT_TEST"
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
agent._compression_lock_ttl_seconds = 1.0
agent._compression_lock_refresh_interval = 0.1
def _aborting_compress(*_a, **_kw):
agent.context_compressor._last_compress_aborted = True
agent.context_compressor._last_summary_error = "summary failed"
return [{"role": "user", "content": "tail"}]
agent.context_compressor.compress.side_effect = _aborting_compress
agent._emit_warning = lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("abort boom"))
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
with pytest.raises(RuntimeError, match="abort boom"):
agent._compress_context(messages, "sys", approx_tokens=120_000)
time.sleep(1.3)
assert db.try_acquire_compression_lock(parent_sid, "probe", ttl_seconds=1.0) is True
def test_internal_typeerror_stops_lock_refresher_without_retry(tmp_path: Path, monkeypatch) -> None:
"""An engine TypeError must release the refreshed lock without a second call."""
real_try_acquire = SessionDB.try_acquire_compression_lock
def _short_ttl(self, session_id: str, holder: str, ttl_seconds: float = 300.0) -> bool:
return real_try_acquire(self, session_id, holder, ttl_seconds=1.0)
monkeypatch.setattr(SessionDB, "try_acquire_compression_lock", _short_ttl)
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "REFRESH_TYPEERROR_TEST"
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
agent._compression_lock_ttl_seconds = 1.0
agent._compression_lock_refresh_interval = 0.1
calls = []
def _internal_typeerror(*_a, **_kw):
calls.append(_kw)
raise TypeError("engine implementation bug")
agent.context_compressor.compress.side_effect = _internal_typeerror
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
with pytest.raises(TypeError, match="engine implementation bug"):
agent._compress_context(messages, "sys", approx_tokens=120_000)
assert len(calls) == 1
time.sleep(1.3)
assert db.try_acquire_compression_lock(parent_sid, "probe", ttl_seconds=1.0) is True
def test_lease_refresher_start_exception_releases_lock(tmp_path: Path, monkeypatch) -> None:
"""A failed refresher start must not strand the lock until its TTL."""
refreshers = []
class FailingLeaseRefresher:
def __init__(self, *_args, **_kwargs):
self.stopped = False
refreshers.append(self)
def start(self):
raise RuntimeError("cannot start lock refresher")
def stop(self):
self.stopped = True
monkeypatch.setattr(
"agent.conversation_compression._CompressionLockLeaseRefresher",
FailingLeaseRefresher,
)
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "REFRESHER_START_EXCEPTION_TEST"
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
with pytest.raises(RuntimeError, match="cannot start lock refresher"):
agent._compress_context(messages, "sys", approx_tokens=120_000)
assert db.get_compression_lock_holder(parent_sid) is None
assert len(refreshers) == 1
assert refreshers[0].stopped is True
def test_signature_introspection_exception_releases_lock_and_refresher(
@ -1074,129 +607,10 @@ def test_signature_introspection_exception_releases_lock_and_refresher(
assert not refreshers[0]._thread.is_alive()
def test_noop_prompt_exception_releases_lock_and_refresher(
tmp_path: Path, monkeypatch
) -> None:
"""No-op prompt rebuild failures must not escape the lock cleanup scope."""
from agent.conversation_compression import (
_CompressionLockLeaseRefresher as RealLeaseRefresher,
)
refreshers = []
class RecordingLeaseRefresher(RealLeaseRefresher):
def start(self):
refreshers.append(self)
return super().start()
monkeypatch.setattr(
"agent.conversation_compression._CompressionLockLeaseRefresher",
RecordingLeaseRefresher,
)
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "NOOP_PROMPT_EXCEPTION_TEST"
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
agent._compression_lock_refresh_interval = 0.1
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
agent.context_compressor.compress.side_effect = lambda *_a, **_kw: messages
agent._cached_system_prompt = None
agent._build_system_prompt = lambda *_a, **_kw: (_ for _ in ()).throw(
RuntimeError("prompt rebuild boom")
)
with pytest.raises(RuntimeError, match="prompt rebuild boom"):
agent._compress_context(messages, "sys", approx_tokens=120_000)
assert db.get_compression_lock_holder(parent_sid) is None
assert len(refreshers) == 1
assert not refreshers[0]._thread.is_alive()
def test_post_dispatch_attribute_exception_releases_lock_and_refresher(
tmp_path: Path, monkeypatch
) -> None:
"""Plugin state lookup failures after dispatch must release the lock."""
from agent.conversation_compression import (
_CompressionLockLeaseRefresher as RealLeaseRefresher,
)
refreshers = []
class RecordingLeaseRefresher(RealLeaseRefresher):
def start(self):
refreshers.append(self)
return super().start()
class AttributeBombEngine:
name = "attribute-bomb"
def compress(self, messages, **_kwargs):
return [messages[0], messages[-1]]
def __getattribute__(self, name):
if name == "_last_compression_made_progress":
raise RuntimeError("post-dispatch attribute boom")
return object.__getattribute__(self, name)
monkeypatch.setattr(
"agent.conversation_compression._CompressionLockLeaseRefresher",
RecordingLeaseRefresher,
)
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "POST_DISPATCH_ATTRIBUTE_EXCEPTION_TEST"
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
agent._compression_lock_refresh_interval = 0.1
agent.context_compressor = AttributeBombEngine()
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
with pytest.raises(RuntimeError, match="post-dispatch attribute boom"):
agent._compress_context(messages, "sys", approx_tokens=120_000)
assert db.get_compression_lock_holder(parent_sid) is None
assert len(refreshers) == 1
assert not refreshers[0]._thread.is_alive()
def test_refresher_stop_exception_does_not_block_lock_release(
tmp_path: Path, monkeypatch
) -> None:
"""Refresher cleanup failure must not prevent holder-qualified DB release."""
refreshers = []
class StopFailingLeaseRefresher:
def __init__(self, *_args, **_kwargs):
self.stop_calls = 0
refreshers.append(self)
def start(self):
return self
def stop(self):
self.stop_calls += 1
raise RuntimeError("refresher stop boom")
monkeypatch.setattr(
"agent.conversation_compression._CompressionLockLeaseRefresher",
StopFailingLeaseRefresher,
)
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "REFRESHER_STOP_EXCEPTION_TEST"
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
agent.context_compressor.compress.side_effect = RuntimeError("engine boom")
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
with pytest.raises(RuntimeError, match="engine boom"):
agent._compress_context(messages, "sys", approx_tokens=120_000)
assert db.get_compression_lock_holder(parent_sid) is None
assert len(refreshers) == 1
assert refreshers[0].stop_calls == 1
def _make_legacy_session_db_class() -> type:
@ -1272,108 +686,12 @@ class _NonCallableLockAPI:
return getattr(self._real, name)
def test_missing_lock_subsystem_fails_open_not_infinite_loop(tmp_path: Path, monkeypatch) -> None:
"""A truly old in-memory SessionDB class must still make progress.
A module reload can update ``conversation_compression`` while the cached
``hermes_state.SessionDB`` class remains pre-lock. The compatibility path is
only valid for that exact class identity, not a proxy that merely uses the
same name.
"""
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "SKEW_TEST_SESSION"
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
legacy_type = _make_legacy_session_db_class()
import hermes_state
real_session_db_type = hermes_state.SessionDB
monkeypatch.setattr(hermes_state, "SessionDB", legacy_type)
try:
# The same module now exposes its genuinely old SessionDB class; its
# instance forwards persistence/rotation operations to a real database.
agent._session_db = legacy_type(db)
monkeypatch.setattr(
"agent.conversation_compression._CompressionLockLeaseRefresher",
lambda *_a, **_k: (_ for _ in ()).throw(
AssertionError("lock refresher should not start on fail-open lock skew")
),
)
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
finally:
monkeypatch.setattr(hermes_state, "SessionDB", real_session_db_type)
assert agent.context_compressor.compress.call_count == 1
assert len(compressed) < len(messages), (
"Compression made no progress despite failing open — loop would still spin."
)
assert agent.session_id != parent_sid
def test_nominal_sessiondb_impostor_fails_closed(tmp_path: Path) -> None:
"""A name/module-spoofing proxy is not the legacy SessionDB compatibility case."""
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "NOMINAL_SESSIONDB_IMPOSTOR_TEST"
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
agent._session_db = _NominalSessionDBImpostor(db)
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
assert compressed is messages or compressed == messages
assert agent.session_id == parent_sid
assert _count_children(db, parent_sid) == 0
agent.context_compressor.compress.assert_not_called()
def test_noncallable_lock_api_fails_closed(tmp_path: Path) -> None:
"""A present but non-callable lock API is not legacy version skew."""
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "NONCALLABLE_LOCK_API_TEST"
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
agent._session_db = _NonCallableLockAPI(db)
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
assert compressed is messages or compressed == messages
assert agent.session_id == parent_sid
assert _count_children(db, parent_sid) == 0
agent.context_compressor.compress.assert_not_called()
@pytest.mark.parametrize(
"error",
[
RuntimeError("simulated lock lookup failure"),
AttributeError("simulated lock lookup attribute error"),
TypeError("simulated lock lookup type error"),
],
)
def test_nonmissing_lock_lookup_errors_fail_closed(
tmp_path: Path, error: Exception
) -> None:
"""Only AttributeError for an absent API may use the compatibility path."""
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "BROKEN_LOCK_LOOKUP_TEST"
db.create_session(parent_sid, source="discord")
agent = _build_agent_with_db(db, parent_sid)
agent._session_db = _BrokenLockLookupDB(db, error)
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
assert compressed is messages or compressed == messages
assert agent.session_id == parent_sid
assert _count_children(db, parent_sid) == 0
agent.context_compressor.compress.assert_not_called()
@pytest.mark.parametrize(
@ -1414,29 +732,6 @@ def test_real_lock_api_internal_errors_fail_closed_skips_compression(
agent.context_compressor.compress.assert_not_called()
def test_post_acquire_error_releases_owned_lock(tmp_path: Path, monkeypatch) -> None:
"""A failure after acquisition commits must not strand the holder lease."""
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "POST_ACQUIRE_ERROR_TEST"
db.create_session(parent_sid, source="discord")
original_acquire = db.try_acquire_compression_lock
def _acquire_then_raise(session_id, holder, ttl_seconds=300.0):
assert original_acquire(session_id, holder, ttl_seconds=ttl_seconds) is True
raise RuntimeError("simulated post-acquire failure")
monkeypatch.setattr(db, "try_acquire_compression_lock", _acquire_then_raise)
agent = _build_agent_with_db(db, parent_sid)
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
assert compressed is messages or compressed == messages
assert agent.session_id == parent_sid
assert _count_children(db, parent_sid) == 0
assert db.get_compression_lock_holder(parent_sid) is None
agent.context_compressor.compress.assert_not_called()
def test_review_fork_disables_compression_to_prevent_stale_parent_fork(tmp_path: Path) -> None:
@ -1542,79 +837,10 @@ def _no_sleep(refresher) -> None:
refresher._stop.wait = lambda _interval: False # type: ignore[assignment]
def test_lease_refresher_survives_single_transient_failure() -> None:
"""One False (transient blip) followed by success must NOT stop the loop.
Regression for the W1/W2 finding: the original ``if not refreshed: break``
treated a one-off failure identically to genuine lost-ownership, killing
the lease on the first hiccup.
"""
from agent.conversation_compression import _CompressionLockLeaseRefresher
# Script: success, FAILURE (blip), success, then stop the loop externally.
db = _FlakyRefreshDB([True, False, True])
refresher = _CompressionLockLeaseRefresher(
db, "sess", "holder", ttl_seconds=10.0, refresh_interval_seconds=0.001
)
# Stop after exactly 4 ticks (3 scripted + 1 steady success), no real sleep.
refresher._stop.wait = lambda _i: db.calls >= 4 # type: ignore[assignment]
refresher._run()
# The single False at call 2 must NOT have ended the loop — we keep going
# past it (calls reach >= 4), proving the blip was tolerated.
assert db.calls >= 4, (
"Lease refresher stopped after a single transient failure — the "
"bounded-tolerance fix regressed (one blip must not kill the lease)."
)
def test_lease_refresher_first_refresh_is_immediate() -> None:
"""Tick #1 must land before the first wait, not one interval late.
The lease clock starts at try_acquire(), but the refresher only starts
after the rotation-ownership lookup, the durable-breaker re-read and thread
startup. Waiting a full interval before the first refresh charges all of
that against the acquirer's first lease, so under load a short TTL can
expire and be reclaimed by a competitor before the owner ever renews.
"""
from agent.conversation_compression import _CompressionLockLeaseRefresher
db = _FlakyRefreshDB([]) # always succeeds
refresher = _CompressionLockLeaseRefresher(
db, "sess", "holder", ttl_seconds=10.0, refresh_interval_seconds=2.0
)
calls_before_first_wait: list[int] = []
def _wait(_interval: float) -> bool:
calls_before_first_wait.append(db.calls)
return True # stop after the first wait
refresher._stop.wait = _wait # type: ignore[assignment]
refresher._run()
assert calls_before_first_wait and calls_before_first_wait[0] == 1, (
"Refresher waited a full interval before its first refresh — the lease "
f"is renewed one interval late (calls at first wait: "
f"{calls_before_first_wait!r})."
)
def test_lease_refresher_immediate_tick_still_honors_stop() -> None:
"""A refresher stopped before/at startup must not fire the immediate tick."""
from agent.conversation_compression import _CompressionLockLeaseRefresher
db = _FlakyRefreshDB([])
refresher = _CompressionLockLeaseRefresher(
db, "sess", "holder", ttl_seconds=10.0, refresh_interval_seconds=2.0
)
refresher._stop.set() # released before the thread got to run
refresher._run()
assert db.calls == 0, (
"The immediate first tick must not resurrect a lock whose owner already "
f"released it (refresh calls after stop(): {db.calls})."
)
def test_lease_refresher_failure_window_is_bounded_by_ttl() -> None:
@ -1645,66 +871,7 @@ def test_lease_refresher_failure_window_is_bounded_by_ttl() -> None:
)
def test_lease_refresher_failure_cap_has_floor_of_one() -> None:
"""A degenerate interval >= ttl still tolerates exactly one blip (floor 1)."""
from agent.conversation_compression import _CompressionLockLeaseRefresher
db = _FlakyRefreshDB([False] * 10)
refresher = _CompressionLockLeaseRefresher(
db, "sess", "holder", ttl_seconds=1.0, refresh_interval_seconds=5.0
)
_no_sleep(refresher)
refresher._run()
assert refresher._max_consecutive_failures == 1
assert db.calls == 1
def test_lease_refresher_recovers_after_raise() -> None:
"""A raise treated as a failure tick must RESET on a later success — the
exception arm gets the same blip-tolerance as a falsy return, not just a
'doesn't crash' guarantee."""
from agent.conversation_compression import _CompressionLockLeaseRefresher
class _RaiseThenOKDB:
"""Raise once, then succeed forever — the transient-blip analog."""
def __init__(self):
self.calls = 0
def refresh_compression_lock(self, *a, **k):
self.calls += 1
if self.calls == 1:
raise RuntimeError("simulated DB hiccup")
return True
db = _RaiseThenOKDB()
refresher = _CompressionLockLeaseRefresher(
db, "sess", "holder", ttl_seconds=10.0, refresh_interval_seconds=2.0
)
# Run a handful of ticks past the raise, then stop.
refresher._stop.wait = lambda _i: db.calls >= 4 # type: ignore[assignment]
refresher._run() # must not propagate the RuntimeError
# Survived the raise and kept refreshing — the counter reset on recovery.
assert db.calls >= 4
def test_lease_refresher_stops_on_persistent_raise() -> None:
"""A refresh that raises every tick is bounded by the same TTL-derived cap,
never propagates, and never loops forever."""
from agent.conversation_compression import _CompressionLockLeaseRefresher
class _AlwaysRaiseDB:
def __init__(self):
self.calls = 0
def refresh_compression_lock(self, *a, **k):
self.calls += 1
raise RuntimeError("simulated DB hiccup")
db = _AlwaysRaiseDB()
refresher = _CompressionLockLeaseRefresher(
db, "sess", "holder", ttl_seconds=10.0, refresh_interval_seconds=2.0
)
_no_sleep(refresher)
refresher._run() # must not propagate
assert db.calls == refresher._max_consecutive_failures

View file

@ -37,32 +37,8 @@ def _patch_task_config(chain):
)
def test_entry_timeout_resolved_from_configured_chain():
chain = [
{"provider": "custom", "timeout": 240},
{"provider": "openrouter"},
]
with _patch_task_config(chain):
assert _fallback_entry_timeout("compression", "fallback_chain[0](custom)") == 240.0
# Entry without a timeout → None (keep task-level).
assert _fallback_entry_timeout("compression", "fallback_chain[1](openrouter)") is None
def test_entry_timeout_ignores_non_chain_labels_and_bad_values():
chain = [{"provider": "custom", "timeout": "fast"}] # invalid type
with _patch_task_config(chain):
# Non-chain labels (main-model fallback, payment fallback, ...) pass through.
assert _fallback_entry_timeout("compression", "anthropic") is None
assert _fallback_entry_timeout("compression", "") is None
assert _fallback_entry_timeout(None, "fallback_chain[0](custom)") is None
# Invalid timeout value → None.
assert _fallback_entry_timeout("compression", "fallback_chain[0](custom)") is None
# Out-of-range index → None, never raises.
with _patch_task_config([]):
assert _fallback_entry_timeout("compression", "fallback_chain[5](x)") is None
# Boolean True is not a valid timeout (bool is an int subclass).
with _patch_task_config([{"provider": "x", "timeout": True}]):
assert _fallback_entry_timeout("compression", "fallback_chain[0](x)") is None
def test_fallback_candidate_call_uses_entry_timeout():
@ -94,29 +70,6 @@ def test_fallback_candidate_call_uses_entry_timeout():
assert seen.get("timeout") == 240.0
def test_fallback_candidate_without_entry_timeout_keeps_task_timeout():
seen = {}
class _FakeCompletions:
def create(self, **kwargs):
seen.update(kwargs)
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))]
)
fb_client = SimpleNamespace(
base_url="https://example.invalid/v1",
chat=SimpleNamespace(completions=_FakeCompletions()),
)
with _patch_task_config([{"provider": "custom"}]):
_call_fallback_candidate_sync(
fb_client, "m", "fallback_chain[0](custom)",
task="compression", messages=[{"role": "user", "content": "hi"}],
temperature=None, max_tokens=None, tools=None,
effective_timeout=300.0,
effective_extra_body={}, reasoning_config=None,
)
assert seen.get("timeout") == 300.0
# ---------------------------------------------------------------------------
@ -145,21 +98,6 @@ def _fail_with_timeout(compressor, now):
return compressor._generate_summary(_msgs())
def test_timeout_cooldown_escalates_and_caps():
c = _make_compressor()
assert _fail_with_timeout(c, 1000.0) is None
assert c._summary_failure_cooldown_until == 1000.0 + 60
assert _fail_with_timeout(c, 2000.0) is None
assert c._summary_failure_cooldown_until == 2000.0 + 300
assert _fail_with_timeout(c, 3000.0) is None
assert c._summary_failure_cooldown_until == 3000.0 + 900
# Capped: a fourth consecutive timeout stays at the ladder max.
assert _fail_with_timeout(c, 4000.0) is None
assert c._summary_failure_cooldown_until == 4000.0 + 900
def test_timeout_streak_resets_on_success():
@ -192,11 +130,3 @@ def test_non_timeout_transient_errors_keep_flat_cooldown():
assert getattr(c, "_consecutive_timeout_failures", 0) == 0
def test_session_reset_clears_timeout_streak():
c = _make_compressor()
assert _fail_with_timeout(c, 1000.0) is None
assert _fail_with_timeout(c, 2000.0) is None
assert c._consecutive_timeout_failures == 2
c.on_session_reset()
assert c._consecutive_timeout_failures == 0

View file

@ -18,15 +18,7 @@ import agent.auxiliary_client as aux
class TestAuxInterruptProtection:
def test_protected_flag_defaults_false(self):
# Fresh thread-local state.
assert aux._aux_interrupt_protected() is False
def test_context_manager_sets_and_restores(self):
assert aux._aux_interrupt_protected() is False
with aux.aux_interrupt_protection():
assert aux._aux_interrupt_protected() is True
assert aux._aux_interrupt_protected() is False
def test_context_manager_is_reentrant(self):
with aux.aux_interrupt_protection():
@ -45,9 +37,6 @@ class TestAuxInterruptProtection:
pass
assert aux._aux_interrupt_protected() is False
def test_explicit_inactive_is_noop(self):
with aux.aux_interrupt_protection(active=False):
assert aux._aux_interrupt_protected() is False
class TestCompressionProtectsSummaryCall:

View file

@ -75,40 +75,11 @@ class TestCompressionMaxAttemptsConfig:
agent = _make_agent(monkeypatch, tmp_path, max_attempts=6)
assert agent.max_compression_attempts == 6
def test_hard_capped_at_ten(self, monkeypatch, tmp_path):
agent = _make_agent(monkeypatch, tmp_path, max_attempts=25)
assert agent.max_compression_attempts == 10
def test_zero_and_negative_fall_back_to_default(self, monkeypatch, tmp_path):
agent = _make_agent(monkeypatch, tmp_path, max_attempts=0)
assert agent.max_compression_attempts == 3
agent = _make_agent(monkeypatch, tmp_path, max_attempts=-2)
assert agent.max_compression_attempts == 3
def test_non_integer_falls_back_to_default(self, monkeypatch, tmp_path):
agent = _make_agent(monkeypatch, tmp_path, max_attempts="lots")
assert agent.max_compression_attempts == 3
def test_boolean_is_rejected_not_coerced(self, monkeypatch, tmp_path):
# bool subclasses int: int(True) == 1 would silently near-disable
# compression retries. YAML `max_attempts: true` must fall back to 3.
agent = _make_agent(monkeypatch, tmp_path, max_attempts=True)
assert agent.max_compression_attempts == 3
agent = _make_agent(monkeypatch, tmp_path, max_attempts=False)
assert agent.max_compression_attempts == 3
def test_fractional_float_is_rejected_not_truncated(self, monkeypatch, tmp_path):
# "4.7 attempts" is a config mistake, not a request for 4.
agent = _make_agent(monkeypatch, tmp_path, max_attempts=4.7)
assert agent.max_compression_attempts == 3
def test_integral_float_and_numeric_string_are_accepted(
self, monkeypatch, tmp_path
):
agent = _make_agent(monkeypatch, tmp_path, max_attempts=5.0)
assert agent.max_compression_attempts == 5
agent = _make_agent(monkeypatch, tmp_path, max_attempts="6")
assert agent.max_compression_attempts == 6
def test_loop_pickup_degrades_to_default_when_attribute_missing(
self, monkeypatch, tmp_path

View file

@ -27,17 +27,7 @@ class TestCompressionMadeProgress:
orig_len=10, new_len=5, orig_tokens=1000, new_tokens=1000
) is True
def test_tokens_reduced_without_row_change_counts_as_progress(self):
"""Issue #39548: 220 → 220 rows, 288k → 183k tokens IS progress."""
assert _compression_made_progress(
orig_len=220, new_len=220, orig_tokens=288_028, new_tokens=183_180
) is True
def test_both_reduced_counts_as_progress(self):
"""Common case: summarising drops some rows and shrinks the rest."""
assert _compression_made_progress(
orig_len=220, new_len=180, orig_tokens=288_028, new_tokens=150_000
) is True
def test_neither_moved_means_no_progress(self):
"""The genuine "stuck" case — same rows, same tokens, give up."""
@ -45,29 +35,8 @@ class TestCompressionMadeProgress:
orig_len=10, new_len=10, orig_tokens=1000, new_tokens=1000
) is False
def test_rows_grew_and_tokens_grew_means_no_progress(self):
"""Pathological: the pass made the request larger — definitely stuck."""
assert _compression_made_progress(
orig_len=10, new_len=12, orig_tokens=1000, new_tokens=1200
) is False
def test_rows_grew_but_tokens_dropped_is_progress(self):
"""Edge: summary rows may expand the row count while shrinking tokens.
Token reduction alone is sufficient to keep the loop going.
"""
assert _compression_made_progress(
orig_len=10, new_len=11, orig_tokens=1000, new_tokens=600
) is True
def test_tokens_grew_but_rows_dropped_is_progress(self):
"""Edge: row reduction alone is sufficient even if tokens nominally
creep up (e.g. summary verbosity). Row-count reduction is a hard
signal that the transcript actually shrank.
"""
assert _compression_made_progress(
orig_len=10, new_len=5, orig_tokens=1000, new_tokens=1100
) is True
def test_sub_5pct_token_drop_is_not_progress(self):
"""A token reduction below the 5% material floor does NOT count as
@ -82,11 +51,6 @@ class TestCompressionMadeProgress:
orig_len=10, new_len=10, orig_tokens=1000, new_tokens=940
) is True
def test_zero_orig_tokens_is_not_progress(self):
"""Degenerate estimate (0 tokens) must not be read as a token win."""
assert _compression_made_progress(
orig_len=10, new_len=10, orig_tokens=0, new_tokens=0
) is False
class TestCompressionWarrantsAnotherPreflightPass:
@ -104,9 +68,3 @@ class TestCompressionWarrantsAnotherPreflightPass:
threshold_tokens=272_000,
) is False
def test_clearing_threshold_needs_no_additional_pass(self):
assert _compression_warrants_another_preflight_pass(
orig_tokens=280_000,
new_tokens=250_000,
threshold_tokens=272_000,
) is False

View file

@ -361,125 +361,8 @@ class TestAutomaticCompressionStateRefreshAfterLock:
compress.assert_not_called()
assert db.get_compression_lock_holder(parent_id) is None
def test_prebound_agent_reloads_persisted_streak_before_compressing(
self,
refresh_state_db: SessionDB,
):
db = refresh_state_db
session_id = "STALE_FALLBACK_BREAKER"
db.create_session(session_id, source="telegram")
db.set_compression_fallback_streak(session_id, 1)
agent = _build_agent_with_db(db, session_id, platform="telegram")
compressor = _bound_context_compressor(db, session_id)
assert compressor._fallback_compression_streak == 1
# A second agent finishes an in-place fallback boundary after this
# call's initial gate but while it is acquiring the session lock.
real_acquire = db.try_acquire_compression_lock
def _acquire_after_fallback(*args, **kwargs):
db.set_compression_fallback_streak(session_id, 2)
return real_acquire(*args, **kwargs)
db.try_acquire_compression_lock = _acquire_after_fallback
agent.context_compressor = compressor
agent.compression_in_place = True
agent._compression_feasibility_checked = True
messages = _msgs()
with patch.object(
compressor,
"compress",
side_effect=AssertionError("stale agent bypassed fallback breaker"),
) as compress:
returned, _ = agent._compress_context(
messages,
"sys",
approx_tokens=120_000,
)
assert returned is messages
assert compressor._fallback_compression_streak == 2
compress.assert_not_called()
assert db.get_compression_lock_holder(session_id) is None
def test_prebound_agent_reloads_persisted_cooldown_before_compressing(
self,
refresh_state_db: SessionDB,
):
db = refresh_state_db
session_id = "STALE_COMPRESSION_COOLDOWN"
db.create_session(session_id, source="telegram")
agent = _build_agent_with_db(db, session_id, platform="telegram")
compressor = _bound_context_compressor(db, session_id)
assert compressor.get_active_compression_failure_cooldown() is None
# Another agent records a provider cooldown after this call's initial
# gate but while it is acquiring the session lock.
real_acquire = db.try_acquire_compression_lock
def _acquire_after_cooldown(*args, **kwargs):
db.record_compression_failure_cooldown(
session_id,
time.time() + 60,
"rate limited",
)
return real_acquire(*args, **kwargs)
db.try_acquire_compression_lock = _acquire_after_cooldown
agent.context_compressor = compressor
agent.compression_in_place = True
agent._compression_feasibility_checked = True
messages = _msgs()
with patch.object(
compressor,
"compress",
side_effect=AssertionError("stale agent bypassed compression cooldown"),
) as compress:
returned, _ = agent._compress_context(
messages,
"sys",
approx_tokens=120_000,
)
assert returned is messages
assert compressor.get_active_compression_failure_cooldown() is not None
compress.assert_not_called()
assert db.get_compression_lock_holder(session_id) is None
def test_prebound_agent_drops_stale_blocker_before_initial_gate(
self,
refresh_state_db: SessionDB,
):
db = refresh_state_db
session_id = "CLEARED_FALLBACK_BREAKER"
db.create_session(session_id, source="telegram")
db.set_compression_fallback_streak(session_id, 2)
agent = _build_agent_with_db(db, session_id, platform="telegram")
compressor = _bound_context_compressor(db, session_id)
assert compressor._fallback_compression_streak == 2
# A healthy boundary on another agent clears the durable breaker after
# this compressor was bound. The initial gate must not remain stuck on
# its stale in-memory snapshot.
db.set_compression_fallback_streak(session_id, 0)
agent.context_compressor = compressor
agent.compression_in_place = True
agent._compression_feasibility_checked = True
messages = _msgs()
with patch.object(compressor, "compress", return_value=messages) as compress:
returned, _ = agent._compress_context(
messages,
"sys",
approx_tokens=120_000,
)
assert returned is messages
assert compressor._fallback_compression_streak == 0
compress.assert_called_once()
assert db.get_compression_lock_holder(session_id) is None
def test_prebound_agent_drops_stale_cooldown_before_initial_gate(
self,
@ -517,37 +400,6 @@ class TestAutomaticCompressionStateRefreshAfterLock:
compress.assert_called_once()
assert db.get_compression_lock_holder(session_id) is None
def test_force_still_bypasses_refreshed_persisted_breaker(
self,
refresh_state_db: SessionDB,
):
db = refresh_state_db
session_id = "FORCED_FALLBACK_RETRY"
db.create_session(session_id, source="telegram")
db.set_compression_fallback_streak(session_id, 2)
agent = _build_agent_with_db(db, session_id, platform="telegram")
compressor = _bound_context_compressor(db, session_id)
agent.context_compressor = compressor
agent.compression_in_place = True
agent._compression_feasibility_checked = True
messages = _msgs()
with patch.object(compressor, "compress", return_value=messages) as compress:
returned, _ = agent._compress_context(
messages,
"sys",
approx_tokens=120_000,
force=True,
)
assert returned is messages
compress.assert_called_once_with(
messages,
current_tokens=120_000,
focus_topic=None,
force=True,
)
assert db.get_compression_lock_holder(session_id) is None
class TestGateLevelGuardRefresh:
@ -691,80 +543,8 @@ class TestTodoSnapshotMergedNotDuplicated:
for previous, current in zip(compressed, compressed[1:])
)
def test_multimodal_snapshot_merges_into_trailing_user_on_rotation(
self, tmp_path: Path
):
db = SessionDB(db_path=tmp_path / "state.db")
parent = "PARENT_TODO_MULTIMODAL_ROTATION"
db.create_session(parent, source="cli")
agent = _build_agent_with_db(db, parent, platform="cli")
original_parts = [
{"type": "text", "text": "tail text"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/context.png"},
},
]
agent.context_compressor.compress.return_value = [
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
{"role": "assistant", "content": "acknowledged"},
{"role": "user", "content": list(original_parts)},
]
agent._todo_store._todos = [
{"id": "t1", "content": "inspect image", "status": "pending"}
]
agent._todo_store.format_for_injection = (
lambda: "## Current Tasks\n- [ ] inspect image"
)
compressed, _ = agent._compress_context(
_msgs(), "sys", approx_tokens=120_000
)
assert len(compressed) == 3
tail = compressed[-1]
assert tail["role"] == "user"
assert isinstance(tail["content"], list)
assert tail["content"][: len(original_parts)] == original_parts
assert any(
isinstance(part, dict) and "inspect image" in (part.get("text") or "")
for part in tail["content"]
)
assert not any(
previous.get("role") == current.get("role") == "user"
for previous, current in zip(compressed, compressed[1:])
)
def test_snapshot_merge_is_persisted_in_place(self, tmp_path: Path):
db = SessionDB(db_path=tmp_path / "state.db")
parent = "PARENT_TODO_INPLACE"
db.create_session(parent, source="cli")
agent = _build_agent_with_db(db, parent, platform="cli")
agent.compression_in_place = True
agent.context_compressor.compress.return_value = [
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "last user msg"},
]
agent._todo_store._todos = [
{"id": "t1", "content": "do thing", "status": "in_progress"}
]
agent._todo_store.format_for_injection = (
lambda: "## Current Tasks\n- [ ] do thing"
)
agent._compress_context(_msgs(), "sys", approx_tokens=120_000)
db_msgs = db.get_messages(agent.session_id)
assert not any(
previous.get("role") == current.get("role") == "user"
for previous, current in zip(db_msgs, db_msgs[1:])
)
last_user = [message for message in db_msgs if message["role"] == "user"][-1]
assert "last user msg" in last_user["content"]
assert "do thing" in last_user["content"]
def test_multimodal_snapshot_merge_is_persisted_in_place(self, tmp_path: Path):
db = SessionDB(db_path=tmp_path / "state.db")
@ -841,115 +621,8 @@ class TestTodoSnapshotScaffoldingTails:
)
return agent
def test_snapshot_stays_standalone_after_continuation_marker(
self, tmp_path: Path
):
from agent.context_compressor import (
COMPRESSION_CONTINUATION_USER_CONTENT,
ContextCompressor,
)
db = SessionDB(db_path=tmp_path / "state.db")
agent = self._agent_with_todo(
db,
"PARENT_TODO_MARKER_TAIL",
{
"role": "user",
"content": COMPRESSION_CONTINUATION_USER_CONTENT,
},
)
compressed, _ = agent._compress_context(
_msgs(), "sys", approx_tokens=120_000
)
tail = compressed[-1]
assert tail["role"] == "user"
assert tail.get("_todo_snapshot_synthetic") is True
assert "task A" in tail["content"]
# The continuation marker keeps its exact text so it stays
# recognizable as scaffolding after SessionDB projection.
marker_rows = [
message
for message in compressed
if message.get("content") == COMPRESSION_CONTINUATION_USER_CONTENT
]
assert len(marker_rows) == 1
# Zero-user provenance: neither the marker nor the snapshot may read
# as a real user turn once SessionDB projection strips the flags
# (#69292). The fixture's stub summary text is not a real handoff
# prefix, so assert on the projected scaffolding rows directly.
assert not ContextCompressor._transcript_has_real_user_turn(
[
{"role": "user", "content": marker_rows[0]["content"]},
{"role": "user", "content": tail["content"]},
]
)
def test_snapshot_stays_standalone_after_summary_as_user_tail(
self, tmp_path: Path
):
from agent.context_compressor import SUMMARY_PREFIX, ContextCompressor
summary_as_user = f"{SUMMARY_PREFIX}\nzero-user summary body"
db = SessionDB(db_path=tmp_path / "state.db")
agent = self._agent_with_todo(
db,
"PARENT_TODO_SUMMARY_TAIL",
{"role": "user", "content": summary_as_user},
)
compressed, _ = agent._compress_context(
_msgs(), "sys", approx_tokens=120_000
)
tail = compressed[-1]
assert tail.get("_todo_snapshot_synthetic") is True
assert "task A" in tail["content"]
# The summary handoff prefix must stay at the START of its own
# message for downstream summary detection.
summary_rows = [
message
for message in compressed
if str(message.get("content") or "").startswith(SUMMARY_PREFIX)
]
assert len(summary_rows) == 1
# Zero-user provenance (#69292): after SessionDB projection strips
# the flags, both the summary-as-user handoff and the standalone
# snapshot must still classify as synthetic — the merge would have
# buried the header/prefix markers mid-content.
assert not ContextCompressor._transcript_has_real_user_turn(
[
{"role": "user", "content": summary_rows[0]["content"]},
{"role": "user", "content": tail["content"]},
]
)
def test_stale_snapshot_row_is_refreshed_not_stacked(self, tmp_path: Path):
from tools.todo_tool import TODO_INJECTION_HEADER
stale = f"{TODO_INJECTION_HEADER}\n- [ ] t0. old finished task (pending)"
db = SessionDB(db_path=tmp_path / "state.db")
agent = self._agent_with_todo(
db,
"PARENT_TODO_STALE_ROW",
{"role": "user", "content": stale},
)
compressed, _ = agent._compress_context(
_msgs(), "sys", approx_tokens=120_000
)
tail = compressed[-1]
assert tail.get("_todo_snapshot_synthetic") is True
assert "task A" in tail["content"]
assert "old finished task" not in tail["content"]
snapshot_rows = [
message
for message in compressed
if str(message.get("content") or "").startswith(TODO_INJECTION_HEADER)
]
assert len(snapshot_rows) == 1
def test_previously_merged_snapshot_is_stripped_before_reinjection(
self, tmp_path: Path

View file

@ -36,22 +36,8 @@ class TestSmallContextThresholdFloor:
assert comp.threshold_percent == 0.75, ctx
assert comp.threshold_tokens == int(ctx * 0.75), ctx
def test_512k_and_above_keep_configured_percent(self):
for ctx in (512_000, 1_000_000):
comp = _make(ctx, pct=0.50)
assert comp.threshold_percent == 0.50, ctx
assert comp.threshold_tokens == int(ctx * 0.50), ctx
def test_raise_only_higher_config_wins(self):
# Explicit 85% (user config or Codex gpt-5.5 autoraise) is not lowered.
comp = _make(128_000, pct=0.85)
assert comp.threshold_percent == 0.85
def test_degenerate_minimum_window_still_uses_85(self):
# 64K window: the MINIMUM_CONTEXT_LENGTH floor pushes the threshold
# to/over the window, so the 85% degenerate-window guard still rules.
comp = _make(64_000, pct=0.50)
assert comp.threshold_tokens == 54_400 # 85% of 64000
def test_update_model_rederives_floor_both_directions(self):
comp = _make(128_000, pct=0.50)
@ -80,12 +66,6 @@ class TestReasoningExcludedFromSummarizer:
assert "visible answer" in ser
assert "other answer" in ser
def test_serializer_excludes_native_reasoning_field(self):
comp = _make(128_000)
turns = [{"role": "assistant", "content": "done", "reasoning": "NATIVE_TRACE"}]
ser = comp._serialize_for_summary(turns)
assert "NATIVE_TRACE" not in ser
assert "done" in ser
def test_summarizer_output_think_block_stripped_before_store(self):
comp = _make(128_000)
@ -108,24 +88,6 @@ class TestReasoningExcludedFromSummarizer:
# across every subsequent compaction.
assert "OUTPUT_TRACE" not in (comp._previous_summary or "")
def test_thinking_only_summarizer_response_not_blanked(self):
# If stripping removes everything (degenerate model output), keep the
# raw content instead of storing an empty summary.
comp = _make(128_000)
class FakeMsg:
content = "<think>only reasoning, no body</think>"
class FakeChoice:
message = FakeMsg()
class FakeResp:
choices = [FakeChoice()]
with patch.object(cc, "call_llm", return_value=FakeResp()):
out = comp._generate_summary([{"role": "user", "content": "hi"}])
# Falls back to unstripped content rather than an empty summary body.
assert out is not None and out.strip()
class TestSummaryBudgetEnvelope:
@ -171,15 +133,7 @@ class TestSummaryBudgetEnvelope:
assert comp._compute_summary_budget(huge) <= 10_000
assert comp.max_summary_tokens <= 10_000
def test_budget_floor_stays_in_envelope(self):
comp = _make(1_000_000)
tiny = [{"role": "user", "content": "hi"}]
budget = comp._compute_summary_budget(tiny)
assert 1_000 <= budget <= 10_000
def test_ceiling_constant_within_envelope(self):
assert 1_000 <= cc._SUMMARY_TOKENS_CEILING <= 10_000
assert 1_000 <= cc._MIN_SUMMARY_TOKENS <= 10_000
class TestTailBudgetProportionality:

View file

@ -68,29 +68,6 @@ def _assert_no_adjacent_user_roles(messages: list[dict]) -> None:
assert (previous.get("role"), current.get("role")) != ("user", "user")
@pytest.mark.parametrize(
"blank",
[
"",
" \n\t",
None,
[],
[{"type": "text", "text": " "}],
[{"type": "input_text", "text": " "}],
],
)
def test_blank_echo_does_not_displace_async_completion(compressor, blank):
completion = "[ASYNC DELEGATION BATCH COMPLETE — deleg_current]\nnew result"
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "old request"},
{"role": "assistant", "content": "old reply"},
{"role": "user", "content": completion},
{"role": "user", "content": blank},
{"role": "assistant", "content": "working from the completion"},
]
assert compressor._find_last_user_message_idx(messages, head_end=1) == 3
def test_leading_blank_without_actionable_user_is_not_removed(compressor):
@ -103,71 +80,8 @@ def test_leading_blank_without_actionable_user_is_not_removed(compressor):
assert compressor._blank_echo_indices_after(messages, -1) == set()
def test_image_only_user_turn_survives_compaction(compressor):
image_content = [
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,AA=="},
}
]
messages: list[dict] = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "old request"},
{"role": "assistant", "content": "old reply"},
]
messages += [
{"role": "user", "content": f"older question {index}"}
if index % 2 == 0
else {"role": "assistant", "content": f"older reply {index}"}
for index in range(6)
]
messages += [
{"role": "user", "content": image_content},
{"role": "user", "content": ""},
{"role": "assistant", "content": "analyzing the image"},
]
_append_tool_run(messages, "image")
result = _compress(compressor, messages)
assert any(message.get("content") == image_content for message in result)
assert all(not compressor._is_blank_user_turn(message) for message in result)
_assert_no_adjacent_user_roles(result)
@pytest.mark.parametrize(
"payload",
[
[{"type": "audio", "source": {"data": "AA=="}}],
[{"type": "input_audio", "input_audio": {"data": "AA=="}}],
[{"type": "future_input", "payload": {"value": 7}}],
],
ids=["audio", "input-audio", "unknown-structured"],
)
def test_structured_non_text_user_turn_survives_compaction(compressor, payload):
messages: list[dict] = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "old request"},
{"role": "assistant", "content": "old reply"},
]
messages += [
{"role": "user", "content": f"older question {index}"}
if index % 2 == 0
else {"role": "assistant", "content": f"older reply {index}"}
for index in range(6)
]
messages += [
{"role": "user", "content": payload},
{"role": "user", "content": ""},
{"role": "assistant", "content": "processing structured input"},
]
_append_tool_run(messages, "structured")
result = _compress(compressor, messages)
assert any(message.get("content") == payload for message in result)
assert all(not compressor._is_blank_user_turn(message) for message in result)
_assert_no_adjacent_user_roles(result)
def test_completion_survives_compaction_verbatim_after_blank_echo(compressor):
@ -269,44 +183,3 @@ def test_completion_at_compress_start_survives_when_blank_echo_is_compress_end(
_assert_no_adjacent_user_roles(result)
def test_tool_call_head_compacts_without_rewriting_event(compressor):
completion = "latest actionable completion"
messages: list[dict] = [
{"role": "user", "content": "initial request"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "head-call",
"function": {"name": "read_file", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "head-call", "content": "head result"},
]
messages += [
{"role": "user", "content": f"older question {index}"}
if index % 2 == 0
else {"role": "assistant", "content": f"older reply {index}"}
for index in range(6)
]
messages += [
{"role": "user", "content": completion},
{"role": "user", "content": ""},
{"role": "assistant", "content": "working"},
]
_append_tool_run(messages, "tail")
result = _compress(compressor, messages)
assert compressor._last_compress_aborted is False
assert any(message.get("content") == completion for message in result)
head = next(
message
for message in result
if any(call.get("id") == "head-call" for call in message.get("tool_calls", []))
)
assert not head.get(COMPRESSED_SUMMARY_METADATA_KEY)
assert any(message.get("tool_call_id") == "head-call" for message in result)
_assert_no_adjacent_user_roles(result)

View file

@ -92,63 +92,9 @@ class TestFindLastAssistantMessageIdx:
messages, head_end=0
) == 2
def test_all_assistant_messages_are_summaries_returns_minus_one(self, compressor):
from agent.context_compressor import SUMMARY_PREFIX
messages = [
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\nold handoff"},
{"role": "user", "content": "continue the task"},
]
assert compressor._find_last_assistant_message_idx(
messages, head_end=0
) == -1
def test_finds_content_bearing_assistant(self, compressor):
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "q"},
{"role": "assistant", "content": "the reply"},
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=1)
assert idx == 2
def test_skips_tool_call_only_stub_when_text_reply_exists_earlier(
self, compressor
):
"""An assistant message that only carries ``tool_calls`` (no
text content) is not the user-visible reply the WebUI
renders those as small "calling tool X" indicators. The helper
must prefer the earlier text reply, which is what the user
actually read."""
messages = [
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "VISIBLE REPLY"},
{"role": "user", "content": "q2"},
{"role": "assistant", "content": None,
"tool_calls": [{"function": {"name": "t",
"arguments": "{}"}}]},
{"role": "tool", "content": "result", "tool_call_id": "c1"},
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
assert idx == 1, (
"Expected the content-bearing assistant reply (1), not the "
f"trailing tool-call stub. Got {idx}."
)
def test_empty_string_content_does_not_count_as_visible(self, compressor):
"""An assistant message with ``content=""`` (only whitespace)
is not a visible reply either common pre-flight stub before
the model streams the real answer."""
messages = [
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "earlier reply"},
{"role": "user", "content": "q2"},
{"role": "assistant", "content": " "}, # blank stub
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
# Blank-string assistant message does not count — fall back
# to the earlier real reply.
assert idx == 1
def test_multimodal_text_block_counts(self, compressor):
"""An assistant with multimodal list-content carrying a text
@ -162,29 +108,7 @@ class TestFindLastAssistantMessageIdx:
idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
assert idx == 1
def test_fallback_to_any_assistant_when_no_content_bearing(
self, compressor
):
"""When there's no text-bearing assistant in the compressible
region (fresh multi-step tool sequence), fall back to the
most recent assistant of any kind so the anchor still works."""
messages = [
{"role": "user", "content": "q"},
{"role": "assistant", "content": None,
"tool_calls": [{"function": {"name": "t",
"arguments": "{}"}}]},
{"role": "tool", "content": "result", "tool_call_id": "c1"},
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
assert idx == 1
def test_returns_negative_one_when_no_assistant(self, compressor):
messages = [
{"role": "user", "content": "q1"},
{"role": "user", "content": "q2"},
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
assert idx == -1
def test_respects_head_end_lower_bound(self, compressor):
"""An assistant message at or before ``head_end`` must be
@ -236,18 +160,6 @@ class TestEnsureLastAssistantMessageInTail:
for m in messages[new_cut:]
)
def test_never_crosses_head_end(self, compressor):
messages = [
{"role": "system", "content": "sys"},
{"role": "assistant", "content": "in-head"}, # head, must ignore
{"role": "user", "content": "q"},
]
# head_end=2 ⇒ assistant at idx 1 is in the head; the anchor
# finds nothing in the compressible region and is a no-op.
new_cut = compressor._ensure_last_assistant_message_in_tail(
messages, cut_idx=3, head_end=2
)
assert new_cut == 3
def test_re_aligns_through_preceding_tool_group(self, compressor):
"""When the anchored assistant is preceded by a
@ -590,11 +502,4 @@ class TestSourceGuardrail:
"backward, and ordering keeps the chain monotonic."
)
def test_helper_prefers_content_bearing_reply(self, source):
"""The helper must skip tool-call-only stubs — that's the
whole user-experience difference between #29824 (no visible
reply) and an in-progress turn (small 'calling tool X' chip)."""
assert "content.strip()" in source
def test_issue_number_referenced(self, source):
assert "#29824" in source

View file

@ -39,14 +39,8 @@ INPUT_TEXT = {"type": "input_text", "text": "hi"}
class TestIsImagePart:
def test_openai_chat_shape(self):
assert _is_image_part(IMG_URL) is True
def test_openai_responses_shape(self):
assert _is_image_part(INPUT_IMG) is True
def test_anthropic_native_shape(self):
assert _is_image_part(ANTHROPIC_IMG) is True
def test_text_part_is_not_image(self):
assert _is_image_part(TEXT) is False
@ -59,32 +53,19 @@ class TestIsImagePart:
class TestContentHasImages:
def test_string_content(self):
assert _content_has_images("a string") is False
def test_empty_list(self):
assert _content_has_images([]) is False
def test_text_only_list(self):
assert _content_has_images([TEXT, TEXT]) is False
def test_list_with_image(self):
assert _content_has_images([TEXT, IMG_URL]) is True
def test_none(self):
assert _content_has_images(None) is False
class TestStripImagesFromContent:
def test_string_passthrough(self):
assert _strip_images_from_content("hello") == "hello"
def test_none_passthrough(self):
assert _strip_images_from_content(None) is None
def test_text_only_passthrough(self):
parts = [TEXT, {"type": "text", "text": "world"}]
assert _strip_images_from_content(parts) == parts
def test_replaces_image_with_placeholder(self):
parts = [TEXT, IMG_URL]
@ -96,11 +77,6 @@ class TestStripImagesFromContent:
"text": "[Attached image — stripped after compression]",
}
def test_does_not_mutate_input(self):
parts = [IMG_URL, TEXT]
_ = _strip_images_from_content(parts)
assert parts[0] is IMG_URL # original list untouched
assert parts[1] is TEXT
def test_handles_all_three_shapes(self):
parts = [IMG_URL, INPUT_IMG, ANTHROPIC_IMG, TEXT]
@ -113,86 +89,12 @@ class TestStripHistoricalMedia:
def test_empty_passthrough(self):
assert _strip_historical_media([]) == []
def test_no_images_anywhere(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hey"},
{"role": "user", "content": "bye"},
]
assert _strip_historical_media(msgs) is msgs # identity — no copy
def test_single_image_user_only_first_message(self):
# Only image-bearing user is the first message — nothing before it.
msgs = [
{"role": "user", "content": [TEXT, IMG_URL]},
{"role": "assistant", "content": "ok"},
]
out = _strip_historical_media(msgs)
assert out is msgs # no-op
# Image still there.
assert _content_has_images(out[0]["content"])
def test_strips_older_user_image_keeps_newest(self):
msgs = [
{"role": "user", "content": [TEXT, IMG_URL]}, # old — strip
{"role": "assistant", "content": "looked at it"},
{"role": "user", "content": [TEXT, INPUT_IMG]}, # newest — keep
]
out = _strip_historical_media(msgs)
assert out is not msgs # new list
# First message's image was replaced
assert not _content_has_images(out[0]["content"])
# Newest user still has its image
assert _content_has_images(out[2]["content"])
def test_strips_assistant_and_tool_images_before_anchor(self):
msgs = [
{"role": "user", "content": [TEXT, IMG_URL]}, # old user
{"role": "assistant", "content": [TEXT, IMG_URL]}, # old assistant
{"role": "tool", "content": [TEXT, IMG_URL], "tool_call_id": "t1"},
{"role": "user", "content": [TEXT, IMG_URL]}, # newest user — keep
]
out = _strip_historical_media(msgs)
for i in range(3):
assert not _content_has_images(out[i]["content"]), f"msg {i} still has image"
assert _content_has_images(out[3]["content"])
def test_text_only_newest_user_still_strips_older_images(self):
# The anchor is "newest user WITH images". If the newest user is
# text-only, we fall back to the previous image-bearing user turn.
msgs = [
{"role": "user", "content": [TEXT, IMG_URL]},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": [TEXT, IMG_URL]}, # anchor
{"role": "assistant", "content": "done"},
{"role": "user", "content": "follow-up text only"},
]
out = _strip_historical_media(msgs)
# First image-bearing user (index 0) was stripped — it was before the
# newest image-bearing user (index 2).
assert not _content_has_images(out[0]["content"])
# Anchor (index 2) keeps its image.
assert _content_has_images(out[2]["content"])
def test_no_image_bearing_user_is_noop(self):
msgs = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": [TEXT, IMG_URL]}, # assistant image only
{"role": "user", "content": "second"},
]
out = _strip_historical_media(msgs)
# No image-bearing user anchor → no stripping.
assert out is msgs
assert _content_has_images(out[1]["content"])
def test_does_not_mutate_input_messages(self):
msg0 = {"role": "user", "content": [TEXT, IMG_URL]}
msg1 = {"role": "user", "content": [TEXT, IMG_URL]}
msgs = [msg0, msg1]
_ = _strip_historical_media(msgs)
# Originals untouched
assert _content_has_images(msg0["content"])
assert _content_has_images(msg1["content"])
def test_idempotent(self):
msgs = [

View file

@ -21,11 +21,7 @@ class TestContentLengthForBudget:
def test_plain_string(self):
assert _content_length_for_budget("hello world") == 11
def test_empty_string(self):
assert _content_length_for_budget("") == 0
def test_none_coerces_to_zero(self):
assert _content_length_for_budget(None) == 0
def test_text_only_list(self):
content = [
@ -34,57 +30,11 @@ class TestContentLengthForBudget:
]
assert _content_length_for_budget(content) == 5 + 6
def test_single_image_part_charges_fixed_budget(self):
content = [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,XXXX"}},
]
# 4 chars of text + 1 image at fixed char-equivalent
assert _content_length_for_budget(content) == 4 + _IMAGE_CHAR_EQUIVALENT
def test_image_url_raw_base64_is_not_counted_as_chars(self):
"""A 1MB base64 blob inside an image_url must NOT inflate token count.
The flat image estimate is what the provider actually bills; the raw
base64 is transport payload, not context tokens.
"""
huge_url = "data:image/png;base64," + ("A" * 1_000_000)
content = [
{"type": "image_url", "image_url": {"url": huge_url}},
]
# Exactly one image's worth, not 1M + something.
assert _content_length_for_budget(content) == _IMAGE_CHAR_EQUIVALENT
def test_multiple_image_parts(self):
content = [
{"type": "text", "text": "compare"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,BBB"}},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,CCC"}},
]
assert _content_length_for_budget(content) == 7 + 3 * _IMAGE_CHAR_EQUIVALENT
def test_openai_responses_input_image_shape(self):
"""Responses API uses type=input_image with top-level image_url string."""
content = [
{"type": "input_text", "text": "hey"},
{"type": "input_image", "image_url": "data:image/png;base64,XX"},
]
# input_text has .text "hey" (3 chars) + 1 image
assert _content_length_for_budget(content) == 3 + _IMAGE_CHAR_EQUIVALENT
def test_anthropic_native_image_shape(self):
"""Anthropic native shape: {type: image, source: {...}}."""
content = [
{"type": "text", "text": "hi"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "XX"}},
]
assert _content_length_for_budget(content) == 2 + _IMAGE_CHAR_EQUIVALENT
def test_bare_string_part_in_list(self):
"""Older code paths sometimes produce mixed list-of-strings content."""
content = ["hello", {"type": "text", "text": "world"}]
assert _content_length_for_budget(content) == 5 + 5
def test_image_estimate_constant_is_reasonable(self):
"""Sanity-check the estimate aligns with real provider billing.

View file

@ -24,21 +24,7 @@ def compressor():
class TestMediaDirectiveStripping:
"""MEDIA directives must be stripped before summarization (#14665)."""
def test_media_directive_stripped_from_assistant(self, compressor):
turns = [
{"role": "assistant", "content": "Here is the audio MEDIA:/tmp/voice.ogg done."},
]
result = compressor._serialize_for_summary(turns)
assert "MEDIA:/tmp/voice.ogg" not in result
assert "[media attachment]" in result
def test_media_directive_stripped_from_tool_result(self, compressor):
turns = [
{"role": "tool", "tool_call_id": "t1", "content": "Generated MEDIA:/tmp/out.mp3 successfully"},
]
result = compressor._serialize_for_summary(turns)
assert "MEDIA:/tmp/out.mp3" not in result
assert "[media attachment]" in result
def test_non_media_content_preserved(self, compressor):
turns = [
@ -76,55 +62,6 @@ class TestMediaDirectiveStripping:
assert "[image]" in result
assert "base64" not in result
def test_multimodal_remote_image_keeps_url(self, compressor):
"""http(s) image parts keep their URL as a referenceable handle."""
turns = [
{
"role": "user",
"content": [
{"type": "text", "text": "look at this"},
{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
],
},
]
result = compressor._serialize_for_summary(turns)
assert "[image: https://example.com/a.png]" in result
def test_multimodal_unknown_part_type_keeps_marker(self, compressor):
"""Unknown part types are not silently dropped."""
turns = [
{
"role": "user",
"content": [
{"type": "text", "text": "see attachment"},
{"type": "document", "title": "spec.pdf"},
],
},
]
result = compressor._serialize_for_summary(turns)
assert "see attachment" in result
assert "[document]" in result
def test_multimodal_list_text_parts_extracted(self, compressor):
"""Text parts from multimodal list content are preserved in output."""
turns = [
{
"role": "user",
"content": [
{"type": "text", "text": "first part"},
{"type": "text", "text": "second part"},
],
},
]
result = compressor._serialize_for_summary(turns)
assert "first part" in result
assert "second part" in result
def test_multimodal_list_bare_strings_handled(self, compressor):
"""Bare strings inside a content list are joined."""
turns = [
{"role": "user", "content": ["hello", "world"]},
]
result = compressor._serialize_for_summary(turns)
assert "hello" in result
assert "world" in result

Some files were not shown because too many files have changed in this diff Show more