mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
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:
commit
92856bc28a
2093 changed files with 2139 additions and 377634 deletions
32
tests/acp/conftest.py
Normal file
32
tests/acp/conftest.py
Normal 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": []},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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("") == ""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 "<tool_call>" 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 "</diagnostics>" 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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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``;
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 ────────────────
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 == {}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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 ----------
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 == ""
|
||||
|
|
|
|||
|
|
@ -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", {})]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue