test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli, cron, tui_gateway, honcho/openviking, root-level): - DELETE: source-reading tests (read_text/getsource on prod files), change-detector tests (exact catalog counts, model-name snapshots, config version literals), mock-echo tests (assert a mock returns what it was told), assertion-free/trivial tests, near-duplicate parametrizations (boundaries + one representative kept), async/sync twin duplicates, cosmetic within-file variations. - KEEP (mandatory): security/redaction/approval guards, message-role alternation invariants, prompt-caching/deterministic-call-id invariants, issue-number regression tests (deduped), E2E tests. - 6 test files deleted outright (script-style/no-assert or fully redundant); conftest.py, fakes/, fixtures/ untouched. - tests/acp/conftest.py added: autouse fixture stubs the live models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server tests performed on every session create — test_server.py 147s → 3.4s, and the tests are now genuinely hermetic. - Sleep-based slowness shrunk where safe (codex_ttfb_watchdog, compression_concurrent_fork, etc.); no wall-clock assertion tightened. Verification: full hermetic suite via scripts/run_tests.sh — 2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall (baseline: 583s wall, 13,564s subprocess CPU).
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,56 +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}},
|
||||
)
|
||||
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"}},
|
||||
)
|
||||
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}},
|
||||
)
|
||||
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}},
|
||||
)
|
||||
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
@@ -99,21 +99,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
|
||||
@@ -129,21 +114,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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -87,24 +87,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):
|
||||
@@ -130,27 +114,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:
|
||||
@@ -165,36 +132,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:
|
||||
|
||||
@@ -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.3)
|
||||
|
||||
monkeypatch.setattr(SessionDB, "try_acquire_compression_lock", _short_ttl)
|
||||
|
||||
@@ -906,7 +536,7 @@ 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_ttl_seconds = 0.3
|
||||
agent._compression_lock_refresh_interval = 0.1
|
||||
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.45)
|
||||
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:
|
||||
|
||||
@@ -72,40 +72,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
|
||||
|
||||
@@ -122,32 +122,7 @@ class TestFloorDoesNotSplitToolGroups:
|
||||
"_sanitize_tool_pairs had to strip an orphan — the cut split a group"
|
||||
)
|
||||
|
||||
def test_floor_lands_on_tool_result_after_protected_head(self, compressor):
|
||||
"""The floor path itself: head_end + 1 points straight at a result."""
|
||||
messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "u" * 60},
|
||||
{"role": "user", "content": "u" * 60},
|
||||
{"role": "user", "content": "u" * 60},
|
||||
]
|
||||
messages += _tool_group("call_1", results=1)
|
||||
|
||||
start, end = _cut(compressor, messages)
|
||||
|
||||
assert not _pairing_violations(messages, start, end)
|
||||
assert messages[end - 1].get("role") != "assistant" or not messages[
|
||||
end - 1
|
||||
].get("tool_calls"), "cut must not sit between a tool_call and its result"
|
||||
|
||||
def test_cut_still_makes_progress(self, compressor):
|
||||
"""The floor's purpose survives: compression always claims a message."""
|
||||
messages = [{"role": "system", "content": "sys"}]
|
||||
messages += _tool_group("call_1", results=1)
|
||||
messages += _tool_group("call_2", results=1)
|
||||
|
||||
start, end = _cut(compressor, messages)
|
||||
|
||||
assert end > start, "compression must not become a no-op"
|
||||
|
||||
|
||||
class TestToolPairingInvariantAcrossShapes:
|
||||
|
||||
@@ -58,15 +58,7 @@ class TestToolCallEnvelopeEstimate:
|
||||
envelope = sum(len(str(tc)) for tc in msg["tool_calls"]) // _CHARS_PER_TOKEN
|
||||
assert new >= envelope
|
||||
|
||||
def test_scales_with_number_of_parallel_calls(self):
|
||||
one = _estimate_msg_budget_tokens(_assistant_with_tool_calls(1))
|
||||
five = _estimate_msg_budget_tokens(_assistant_with_tool_calls(5))
|
||||
assert five > one * 3
|
||||
|
||||
def test_no_tool_calls_matches_content_estimate(self):
|
||||
msg = {"role": "user", "content": "x" * 400}
|
||||
# Plain message: content//4 + 10 overhead, behavior unchanged.
|
||||
assert _estimate_msg_budget_tokens(msg) == 400 // _CHARS_PER_TOKEN + 10
|
||||
|
||||
def test_non_dict_tool_calls_do_not_crash(self):
|
||||
msg = {"role": "assistant", "content": "hi", "tool_calls": ["weird", None]}
|
||||
|
||||
@@ -105,30 +105,6 @@ class TestCompressAlwaysKeepsAUserTurn:
|
||||
f"non-retryable 400. Role histogram: {hist}"
|
||||
)
|
||||
|
||||
def test_summary_pinned_to_user_when_no_user_survives(self, compressor):
|
||||
"""When the whole compressible region is assistant/tool and no
|
||||
user message survives in head or tail, the inserted summary
|
||||
itself must be the user turn."""
|
||||
from agent.context_compressor import (
|
||||
SUMMARY_PREFIX,
|
||||
COMPRESSED_SUMMARY_METADATA_KEY,
|
||||
)
|
||||
|
||||
c = compressor
|
||||
c.compression_count = 1
|
||||
messages = [{"role": "user", "content": "work kanban task 7"}]
|
||||
messages += _tool_turns(0, 12)
|
||||
|
||||
mocked = f"{SUMMARY_PREFIX}\nsummary body"
|
||||
with patch.object(c, "_generate_summary", return_value=mocked):
|
||||
out = c.compress(messages, current_tokens=90_000)
|
||||
|
||||
summary_rows = [m for m in out if m.get(COMPRESSED_SUMMARY_METADATA_KEY)]
|
||||
assert len(summary_rows) == 1
|
||||
assert summary_rows[0].get("role") == "user", (
|
||||
"The handoff summary must carry role=user when it is the only "
|
||||
"possible user turn in the compressed transcript (#58753)."
|
||||
)
|
||||
|
||||
def test_no_consecutive_user_roles_introduced(self, compressor):
|
||||
"""Forcing the summary to role=user must not create two
|
||||
|
||||
@@ -50,14 +50,6 @@ def test_breakdown_includes_major_categories():
|
||||
assert data["estimated_total"] > 0
|
||||
|
||||
|
||||
def test_breakdown_uses_measured_context_when_available():
|
||||
agent, parts = _make_agent(last_prompt_tokens=42_000)
|
||||
|
||||
with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts):
|
||||
data = compute_session_context_breakdown(agent, [])
|
||||
|
||||
assert data["context_used"] == 42_000
|
||||
assert data["context_percent"] == 21
|
||||
|
||||
# ── /context renderers (pure functions over the payload) ────────────────────
|
||||
|
||||
@@ -100,34 +92,12 @@ def test_grid_is_5x20_and_mostly_free():
|
||||
assert cells.count("▣") == 10
|
||||
|
||||
|
||||
def test_grid_nonzero_category_never_invisible():
|
||||
payload = _payload(
|
||||
categories=[{"id": "memory", "label": "Memory", "tokens": 10}],
|
||||
estimated_total=10,
|
||||
context_used=10,
|
||||
)
|
||||
rows = render_context_grid(payload)
|
||||
assert "▧" in " ".join(rows)
|
||||
|
||||
|
||||
def test_grid_without_context_max_is_all_free():
|
||||
rows = render_context_grid(_payload(context_max=0))
|
||||
cells = " ".join(rows).split(" ")
|
||||
assert set(cells) == {"·"}
|
||||
|
||||
|
||||
def test_category_lines_include_tokens_percent_and_free_space():
|
||||
lines = render_context_category_lines(_payload())
|
||||
text = "\n".join(lines)
|
||||
assert "Estimated usage by category" in text
|
||||
assert "System prompt" in text and "10,000 tokens" in text
|
||||
assert "5.0%" in text # 10k / 200k
|
||||
assert "Free space" in text and "150,000 tokens" in text
|
||||
|
||||
|
||||
def test_category_lines_no_categories():
|
||||
lines = render_context_category_lines(_payload(categories=[]))
|
||||
assert any("no data yet" in line for line in lines)
|
||||
|
||||
|
||||
def test_breakdown_lines_grid_toggle():
|
||||
@@ -142,24 +112,6 @@ def test_breakdown_lines_grid_toggle():
|
||||
assert "/context all" in text
|
||||
|
||||
|
||||
def test_breakdown_lines_with_details_omits_hint():
|
||||
details = {
|
||||
"skills": [
|
||||
{"name": "alpha", "index_tokens": 25, "skill_md_tokens": 800},
|
||||
{"name": "beta", "index_tokens": 30, "skill_md_tokens": None},
|
||||
],
|
||||
"toolsets": [
|
||||
{"toolset": "terminal", "tool_count": 3, "schema_tokens": 4_000},
|
||||
],
|
||||
}
|
||||
lines = render_context_breakdown_lines(_payload(), details=details, grid=False)
|
||||
text = "\n".join(lines)
|
||||
assert "Toolsets by schema cost" in text
|
||||
assert "terminal" in text and "4,000 tokens" in text
|
||||
assert "Skills by cost" in text
|
||||
assert "alpha" in text and "beta" in text
|
||||
assert "n/a" in text # unmapped SKILL.md renders n/a, not a crash
|
||||
assert "Use /context all" not in text
|
||||
|
||||
|
||||
def test_details_lines_caps_listing():
|
||||
@@ -174,31 +126,3 @@ def test_details_lines_caps_listing():
|
||||
assert any("… and 5 more" in line for line in lines)
|
||||
|
||||
|
||||
def test_compute_context_details_maps_bytes_to_tokens():
|
||||
agent, parts = _make_agent(
|
||||
stable=(
|
||||
"base\n<available_skills>\n demo:\n"
|
||||
" - hello: a demo skill\n</available_skills>"
|
||||
),
|
||||
)
|
||||
fake_skills = [{
|
||||
"name": "hello",
|
||||
"index_line_bytes": 40,
|
||||
"index_line_total_bytes": 40,
|
||||
"index_line_shared_bytes": 0,
|
||||
"index_line_skill_count": 1,
|
||||
"skill_md_bytes": 401,
|
||||
"path": "/tmp/hello/SKILL.md",
|
||||
}]
|
||||
fake_toolsets = [{"toolset": "terminal", "tool_count": 2, "json_bytes": 399}]
|
||||
with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts), \
|
||||
patch("hermes_cli.prompt_size._compute_skills_breakdown", return_value=fake_skills), \
|
||||
patch("hermes_cli.prompt_size._compute_toolsets_breakdown", return_value=fake_toolsets):
|
||||
details = compute_context_details(agent)
|
||||
|
||||
assert details["skills"] == [
|
||||
{"name": "hello", "index_tokens": 10, "skill_md_tokens": 101},
|
||||
]
|
||||
assert details["toolsets"] == [
|
||||
{"toolset": "terminal", "tool_count": 2, "schema_tokens": 100},
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,105 +95,8 @@ def _simulate_cron_session_state(c):
|
||||
c.awaiting_real_usage_after_compression = True
|
||||
|
||||
|
||||
def test_on_session_end_clears_all_per_session_state():
|
||||
"""on_session_end() must clear every per-session variable, not just
|
||||
_previous_summary. Otherwise stale state from a prior session
|
||||
(e.g. a cron job) contaminates the next live session."""
|
||||
c = _make_compressor()
|
||||
_simulate_cron_session_state(c)
|
||||
|
||||
c.on_session_end("cron-session-1", [])
|
||||
|
||||
assert c._previous_summary is None, (
|
||||
f"_previous_summary must be None after on_session_end, got {c._previous_summary!r}"
|
||||
)
|
||||
assert c._last_summary_error is None, (
|
||||
f"_last_summary_error must be None after on_session_end, got {c._last_summary_error!r}"
|
||||
)
|
||||
assert c._last_summary_dropped_count == 0, (
|
||||
f"_last_summary_dropped_count must be 0, got {c._last_summary_dropped_count}"
|
||||
)
|
||||
assert c._last_summary_fallback_used is False, (
|
||||
f"_last_summary_fallback_used must be False, got {c._last_summary_fallback_used}"
|
||||
)
|
||||
assert c._last_aux_model_failure_error is None, (
|
||||
f"_last_aux_model_failure_error must be None, got {c._last_aux_model_failure_error!r}"
|
||||
)
|
||||
assert c._last_aux_model_failure_model is None, (
|
||||
f"_last_aux_model_failure_model must be None, got {c._last_aux_model_failure_model!r}"
|
||||
)
|
||||
assert c._last_compression_savings_pct == 100.0, (
|
||||
f"_last_compression_savings_pct must be 100.0, got {c._last_compression_savings_pct}"
|
||||
)
|
||||
assert c._ineffective_compression_count == 0, (
|
||||
f"_ineffective_compression_count must be 0, got {c._ineffective_compression_count}"
|
||||
)
|
||||
assert c._summary_failure_cooldown_until == 0.0, (
|
||||
f"_summary_failure_cooldown_until must be 0.0, got {c._summary_failure_cooldown_until}"
|
||||
)
|
||||
assert c._last_compress_aborted is False, (
|
||||
f"_last_compress_aborted must be False, got {c._last_compress_aborted}"
|
||||
)
|
||||
assert c._context_probed is False, (
|
||||
f"_context_probed must be False, got {c._context_probed}"
|
||||
)
|
||||
assert c._context_probe_persistable is False, (
|
||||
f"_context_probe_persistable must be False, got {c._context_probe_persistable}"
|
||||
)
|
||||
assert c.last_real_prompt_tokens == 0, (
|
||||
f"last_real_prompt_tokens must be 0, got {c.last_real_prompt_tokens}"
|
||||
)
|
||||
assert c.last_compression_rough_tokens == 0, (
|
||||
f"last_compression_rough_tokens must be 0, got {c.last_compression_rough_tokens}"
|
||||
)
|
||||
assert c.last_rough_tokens_when_real_prompt_fit == 0, (
|
||||
f"last_rough_tokens_when_real_prompt_fit must be 0, got {c.last_rough_tokens_when_real_prompt_fit}"
|
||||
)
|
||||
assert c.awaiting_real_usage_after_compression is False, (
|
||||
f"awaiting_real_usage_after_compression must be False, got {c.awaiting_real_usage_after_compression}"
|
||||
)
|
||||
|
||||
|
||||
def test_on_session_end_matches_on_session_reset_surface():
|
||||
"""Both on_session_end and on_session_reset must clear the same set of
|
||||
per-session variables. If one is updated and the other isn't, it's a
|
||||
cross-session contamination bug waiting to happen."""
|
||||
c1 = _make_compressor()
|
||||
c2 = _make_compressor()
|
||||
_simulate_cron_session_state(c1)
|
||||
_simulate_cron_session_state(c2)
|
||||
|
||||
c1.on_session_end("session-1", [])
|
||||
c2.on_session_reset()
|
||||
|
||||
per_session_attrs = [
|
||||
"_previous_summary",
|
||||
"_summary_has_user_turn",
|
||||
"_last_summary_error",
|
||||
"_last_summary_dropped_count",
|
||||
"_last_summary_fallback_used",
|
||||
"_last_aux_model_failure_error",
|
||||
"_last_aux_model_failure_model",
|
||||
"_last_compression_savings_pct",
|
||||
"_ineffective_compression_count",
|
||||
"_summary_failure_cooldown_until",
|
||||
"_last_compress_aborted",
|
||||
"_context_probed",
|
||||
"_context_probe_persistable",
|
||||
"last_real_prompt_tokens",
|
||||
"last_compression_rough_tokens",
|
||||
"last_rough_tokens_when_real_prompt_fit",
|
||||
"awaiting_real_usage_after_compression",
|
||||
]
|
||||
|
||||
for attr in per_session_attrs:
|
||||
v_end = getattr(c1, attr)
|
||||
v_reset = getattr(c2, attr)
|
||||
assert v_end == v_reset, (
|
||||
f"on_session_end and on_session_reset must produce the same "
|
||||
f"value for {attr}: on_session_end={v_end!r}, "
|
||||
f"on_session_reset={v_reset!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_ineffective_compression_count_does_not_leak_across_sessions():
|
||||
|
||||
@@ -88,59 +88,10 @@ def _messages_with_summary_at_index(summary_index: int):
|
||||
return msgs
|
||||
|
||||
|
||||
def test_existing_previous_summary_is_not_serialized_again_as_new_turn():
|
||||
"""Same-process iterative compression should not feed the old handoff twice."""
|
||||
compressor = _compressor()
|
||||
old_summary = "OLD-SUMMARY-BODY unique continuity facts"
|
||||
compressor._previous_summary = old_summary
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call:
|
||||
compressor.compress(_messages_with_handoff(old_summary))
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "PREVIOUS SUMMARY:" in prompt
|
||||
assert "NEW TURNS TO INCORPORATE:" in prompt
|
||||
assert prompt.count(old_summary) == 1
|
||||
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt
|
||||
|
||||
|
||||
def test_resume_rehydrates_previous_summary_from_handoff_message():
|
||||
"""After restart/resume, the persisted handoff should regain summary identity."""
|
||||
compressor = _compressor()
|
||||
old_summary = "RESUMED-SUMMARY-BODY durable continuity facts"
|
||||
assert compressor._previous_summary is None
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call:
|
||||
compressor.compress(_messages_with_handoff(old_summary))
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "PREVIOUS SUMMARY:" in prompt
|
||||
assert "NEW TURNS TO INCORPORATE:" in prompt
|
||||
assert "TURNS TO SUMMARIZE:" not in prompt
|
||||
assert prompt.count(old_summary) == 1
|
||||
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt
|
||||
|
||||
|
||||
def test_handoff_in_protected_head_populates_previous_summary_before_update():
|
||||
"""A resumed protected-head handoff should restore iterative-summary state."""
|
||||
compressor = _compressor()
|
||||
old_summary = "PROTECTED-HEAD-SUMMARY durable facts from before restart"
|
||||
seen_turns = []
|
||||
|
||||
def fake_generate_summary(
|
||||
turns_to_summarize,
|
||||
focus_topic=None,
|
||||
memory_context="",
|
||||
):
|
||||
seen_turns.extend(turns_to_summarize)
|
||||
return "new summary from resumed turns"
|
||||
|
||||
with patch.object(compressor, "_generate_summary", side_effect=fake_generate_summary):
|
||||
compressor.compress(_messages_with_handoff(old_summary))
|
||||
|
||||
assert compressor._previous_summary == old_summary
|
||||
assert seen_turns
|
||||
assert all(old_summary not in str(msg.get("content", "")) for msg in seen_turns)
|
||||
|
||||
|
||||
def test_handoff_in_protected_head_is_replaced_not_duplicated():
|
||||
@@ -166,40 +117,8 @@ def test_handoff_in_protected_head_is_replaced_not_duplicated():
|
||||
assert old_summary not in "\n".join(str(msg.get("content") or "") for msg in compressed)
|
||||
|
||||
|
||||
def test_recompression_drops_prior_protected_handoff_from_output():
|
||||
"""Repeated compression must not preserve stale handoff bubbles forever."""
|
||||
compressor = _compressor()
|
||||
old_summary = "DUPLICATE-HANDOFF-BODY unique old facts"
|
||||
|
||||
with patch.object(
|
||||
compressor,
|
||||
"_generate_summary",
|
||||
return_value=ContextCompressor._with_summary_prefix(
|
||||
"updated summary with old facts folded in"
|
||||
),
|
||||
):
|
||||
result = compressor.compress(_messages_with_handoff(old_summary))
|
||||
|
||||
joined = "\n".join(str(message.get("content", "")) for message in result)
|
||||
assert old_summary not in joined
|
||||
assert joined.count(SUMMARY_PREFIX) == 1
|
||||
assert "updated summary with old facts folded in" in joined
|
||||
|
||||
|
||||
def test_legacy_string_merged_handoff_preserves_real_tail_text():
|
||||
"""Pre-delimiter string handoffs still unwrap content after the end marker."""
|
||||
message = {
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"{SUMMARY_PREFIX}\nold summary\n\n"
|
||||
f"{_SUMMARY_END_MARKER}\n\nreal tail message"
|
||||
),
|
||||
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
||||
}
|
||||
|
||||
result = ContextCompressor._strip_context_summary_handoff_message(message)
|
||||
|
||||
assert result == {"role": "user", "content": "real tail message"}
|
||||
|
||||
|
||||
def test_recompression_of_current_merged_handoff_preserves_prior_tail_once():
|
||||
@@ -244,93 +163,10 @@ def test_recompression_of_current_merged_handoff_preserves_prior_tail_once():
|
||||
assert "fresh replacement summary" in joined
|
||||
|
||||
|
||||
def test_current_multimodal_merged_handoff_preserves_original_blocks():
|
||||
"""Unwrapping current list content must retain text and image blocks."""
|
||||
prior_text = {"type": "text", "text": "real multimodal tail"}
|
||||
prior_image = {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,AAAA"},
|
||||
}
|
||||
message = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": f"{_MERGED_PRIOR_CONTEXT_HEADER}\n"},
|
||||
prior_text,
|
||||
prior_image,
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"\n\n{_MERGED_SUMMARY_DELIMITER}\n\n"
|
||||
f"{SUMMARY_PREFIX}\nstale summary\n\n{_SUMMARY_END_MARKER}"
|
||||
),
|
||||
},
|
||||
],
|
||||
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
||||
}
|
||||
|
||||
result = ContextCompressor._strip_context_summary_handoff_message(message)
|
||||
|
||||
assert result == {
|
||||
"role": "user",
|
||||
"content": [prior_text, prior_image],
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_multimodal_merged_handoff_preserves_original_blocks():
|
||||
"""Persisted pre-delimiter list handoffs must not lose their real tail."""
|
||||
prior_text = {"type": "text", "text": "legacy real tail"}
|
||||
prior_image = {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,BBBB"},
|
||||
}
|
||||
message = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"{SUMMARY_PREFIX}\nlegacy stale summary\n\n"
|
||||
f"{_SUMMARY_END_MARKER}\n\n"
|
||||
),
|
||||
},
|
||||
prior_text,
|
||||
prior_image,
|
||||
],
|
||||
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
||||
}
|
||||
|
||||
result = ContextCompressor._strip_context_summary_handoff_message(message)
|
||||
|
||||
assert result == {
|
||||
"role": "user",
|
||||
"content": [prior_text, prior_image],
|
||||
}
|
||||
|
||||
|
||||
def test_resume_handoff_in_protected_head_is_not_preserved_as_fossil():
|
||||
"""After restart, a persisted handoff summary should decay head protection."""
|
||||
compressor = _compressor()
|
||||
old_summary = "RESTART-FOSSIL-SUMMARY durable facts from before restart"
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")):
|
||||
result = compressor.compress(_messages_with_handoff(old_summary))
|
||||
|
||||
# Main's task-snapshot grounding (761a0b124e) prepends a deterministic
|
||||
# "## Historical Task Snapshot" section to the stored summary — pin the
|
||||
# contract (fresh body present, fossil absent), not the exact string.
|
||||
stored_summary = compressor._previous_summary or ""
|
||||
assert stored_summary.endswith("fresh summary")
|
||||
assert old_summary not in stored_summary
|
||||
summary_messages = [
|
||||
msg for msg in result
|
||||
if ContextCompressor._has_compressed_summary_metadata(msg)
|
||||
or ContextCompressor._is_context_summary_content(msg.get("content"))
|
||||
]
|
||||
assert len(summary_messages) == 1
|
||||
assert all(
|
||||
old_summary not in str(msg.get("content", ""))
|
||||
for msg in result
|
||||
)
|
||||
|
||||
|
||||
def test_resume_handoff_after_default_protected_head_decays_initial_turns():
|
||||
@@ -405,76 +241,10 @@ def test_restart_simulation_fresh_compressor_does_not_reprotect_head():
|
||||
assert "original answer before first compaction" not in result_text
|
||||
|
||||
|
||||
def test_tail_summary_marker_does_not_decay_first_compaction_head():
|
||||
"""A live tail summary-looking message should not mimic a resumed handoff."""
|
||||
compressor = _compressor(protect_first_n=3)
|
||||
tail_summary = "TAIL-SUMMARY-LIKE message belongs to current protected tail"
|
||||
msgs = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": "HEAD-ONE original request"},
|
||||
{"role": "assistant", "content": "HEAD-TWO original answer"},
|
||||
{"role": "user", "content": "HEAD-THREE original follow-up"},
|
||||
{"role": "assistant", "content": "middle answer one"},
|
||||
{"role": "user", "content": "middle request two"},
|
||||
{"role": "assistant", "content": "middle answer two"},
|
||||
{"role": "user", "content": "middle request three"},
|
||||
{"role": "assistant", "content": "middle answer three"},
|
||||
{"role": "user", "content": "middle request four"},
|
||||
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{tail_summary}"},
|
||||
{"role": "user", "content": "final active request stays in protected tail"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")):
|
||||
result = compressor.compress(msgs)
|
||||
|
||||
result_text = "\n".join(str(msg.get("content", "")) for msg in result)
|
||||
assert "HEAD-ONE original request" in result_text
|
||||
assert "HEAD-TWO original answer" in result_text
|
||||
assert "HEAD-THREE original follow-up" in result_text
|
||||
assert tail_summary not in result_text
|
||||
|
||||
|
||||
def test_restart_handoff_in_protected_tail_is_folded_not_preserved():
|
||||
"""Short resumed transcripts should not copy old summaries as tail."""
|
||||
compressor = _compressor(protect_first_n=3)
|
||||
old_summary = "TAIL-PROTECTED-OLD-SUMMARY durable facts"
|
||||
|
||||
msgs = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": "original task"},
|
||||
{"role": "assistant", "content": "original answer"},
|
||||
{"role": "user", "content": "original follow-up"},
|
||||
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"},
|
||||
{"role": "user", "content": "active request"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")) as mock_call:
|
||||
result = compressor.compress(msgs)
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "PREVIOUS SUMMARY:" in prompt
|
||||
assert prompt.count(old_summary) == 1
|
||||
result_text = "\n".join(str(msg.get("content", "")) for msg in result)
|
||||
assert old_summary not in result_text
|
||||
assert "active request" in result_text
|
||||
assert sum(
|
||||
1 for msg in result if ContextCompressor._is_context_summary_message(msg)
|
||||
) == 1
|
||||
|
||||
|
||||
def test_restart_handoff_fallback_preserves_rehydrated_summary_body():
|
||||
"""Deterministic fallback should retain the rehydrated old summary."""
|
||||
compressor = _compressor(protect_first_n=3)
|
||||
old_summary = "FALLBACK-OLD-SUMMARY durable fact must survive"
|
||||
|
||||
with patch.object(compressor, "_generate_summary", return_value=None):
|
||||
result = compressor.compress(_messages_with_default_handoff(old_summary))
|
||||
|
||||
result_text = "\n".join(str(msg.get("content", "")) for msg in result)
|
||||
assert result_text.count(old_summary) == 1
|
||||
assert sum(
|
||||
1 for msg in result if ContextCompressor._is_context_summary_message(msg)
|
||||
) == 1
|
||||
|
||||
|
||||
def test_zero_protect_first_n_still_folds_restart_fossil():
|
||||
@@ -501,29 +271,6 @@ def test_zero_protect_first_n_still_folds_restart_fossil():
|
||||
) == 1
|
||||
|
||||
|
||||
def test_fossil_beyond_restart_probe_window_is_still_folded():
|
||||
"""Self-heal should find summaries that drift past the decay probe."""
|
||||
compressor = _compressor(protect_first_n=1)
|
||||
old_summary = "OLD-SUMMARY-FAR-FROM-HEAD durable facts"
|
||||
msgs = [{"role": "system", "content": "system prompt"}]
|
||||
msgs += [
|
||||
{
|
||||
"role": "user" if idx % 2 else "assistant",
|
||||
"content": f"filler {idx}",
|
||||
}
|
||||
for idx in range(1, 6)
|
||||
]
|
||||
msgs += [
|
||||
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"},
|
||||
{"role": "user", "content": "active request"},
|
||||
]
|
||||
|
||||
assert compressor._effective_protect_first_n(msgs) == compressor.protect_first_n
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")):
|
||||
result = compressor.compress(msgs)
|
||||
|
||||
assert all(old_summary not in str(msg.get("content", "")) for msg in result)
|
||||
|
||||
|
||||
def test_restart_fossil_survives_summary_abort_then_retry():
|
||||
@@ -576,33 +323,6 @@ def test_restart_fossil_survives_summary_abort_then_retry():
|
||||
) == 1
|
||||
|
||||
|
||||
def test_tail_turns_before_late_handoff_are_not_lost():
|
||||
"""Live tail turns before a late handoff should be summarized or kept."""
|
||||
compressor = _compressor(protect_first_n=3)
|
||||
old_summary = "LATE-TAIL-OLD-SUMMARY"
|
||||
msgs = [{"role": "system", "content": "system prompt"}]
|
||||
msgs += [
|
||||
{
|
||||
"role": "user" if idx % 2 else "assistant",
|
||||
"content": f"body {idx}",
|
||||
}
|
||||
for idx in range(1, 9)
|
||||
]
|
||||
msgs += [
|
||||
{"role": "assistant", "content": "TAIL-BEFORE-SUMMARY-A"},
|
||||
{"role": "user", "content": "TAIL-BEFORE-SUMMARY-B"},
|
||||
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"},
|
||||
{"role": "user", "content": "final active request"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")) as mock_call:
|
||||
result = compressor.compress(msgs)
|
||||
|
||||
preserved = mock_call.call_args.kwargs["messages"][0]["content"] + "\n" + "\n".join(
|
||||
str(msg.get("content", "")) for msg in result
|
||||
)
|
||||
assert "TAIL-BEFORE-SUMMARY-A" in preserved
|
||||
assert "TAIL-BEFORE-SUMMARY-B" in preserved
|
||||
|
||||
|
||||
def test_forced_leading_merged_summary_strips_live_tail_from_summary_body():
|
||||
@@ -617,114 +337,12 @@ def test_forced_leading_merged_summary_strips_live_tail_from_summary_body():
|
||||
assert ContextCompressor._strip_summary_prefix(merged) == "SUMMARY_BODY"
|
||||
|
||||
|
||||
def test_restart_probe_boundary_summary_just_inside_window_decays():
|
||||
"""A summary at the last restart-probe index should still decay."""
|
||||
compressor = _compressor(protect_first_n=3)
|
||||
first_non_system = 1
|
||||
last_probe_idx = (
|
||||
first_non_system
|
||||
+ compressor.protect_first_n
|
||||
+ _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES
|
||||
- 1
|
||||
)
|
||||
|
||||
assert (
|
||||
compressor._effective_protect_first_n(
|
||||
_messages_with_summary_at_index(last_probe_idx)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_restart_probe_boundary_summary_just_outside_window_does_not_decay():
|
||||
"""A summary past the restart-probe window should not decay."""
|
||||
compressor = _compressor(protect_first_n=3)
|
||||
first_non_system = 1
|
||||
first_outside_probe_idx = (
|
||||
first_non_system
|
||||
+ compressor.protect_first_n
|
||||
+ _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES
|
||||
)
|
||||
|
||||
assert (
|
||||
compressor._effective_protect_first_n(
|
||||
_messages_with_summary_at_index(first_outside_probe_idx)
|
||||
)
|
||||
== compressor.protect_first_n
|
||||
)
|
||||
|
||||
|
||||
def test_restart_stacked_handoffs_fold_stray_head_and_collapse_to_single_summary():
|
||||
"""Stacked restart summaries should keep stray head turns as new input."""
|
||||
compressor = _compressor(protect_first_n=3)
|
||||
old_summary = "OLD-ONLY facts from the first compaction"
|
||||
newer_summary = "NEW-ONLY facts from work after restart"
|
||||
|
||||
msgs = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": "FOSSIL-HEAD-TURN live detail before summary"},
|
||||
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"},
|
||||
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{newer_summary}"},
|
||||
{"role": "assistant", "content": "work after restart"},
|
||||
{"role": "user", "content": "more work after restart"},
|
||||
{"role": "assistant", "content": "tail answer"},
|
||||
{"role": "user", "content": "active tail request"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")) as mock_call:
|
||||
result = compressor.compress(msgs)
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "PREVIOUS SUMMARY:" in prompt
|
||||
assert prompt.count(old_summary) == 1
|
||||
assert prompt.count(newer_summary) == 1
|
||||
assert "FOSSIL-HEAD-TURN live detail before summary" in prompt
|
||||
assert f"[ASSISTANT]: {SUMMARY_PREFIX}" not in prompt
|
||||
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt
|
||||
summary_messages = [
|
||||
msg for msg in result
|
||||
if ContextCompressor._is_context_summary_message(msg)
|
||||
]
|
||||
assert len(summary_messages) == 1
|
||||
assert all(old_summary not in str(msg.get("content", "")) for msg in result)
|
||||
assert all(newer_summary not in str(msg.get("content", "")) for msg in result)
|
||||
# The stray head turn must be folded into the summary, not preserved as
|
||||
# its own verbatim message. Main's task-snapshot grounding (761a0b124e)
|
||||
# may legitimately QUOTE it inside the summary handoff as the
|
||||
# deterministic "User asked" anchor, so only non-summary messages are
|
||||
# checked for the verbatim fossil.
|
||||
assert all(
|
||||
"FOSSIL-HEAD-TURN" not in str(msg.get("content", ""))
|
||||
for msg in result
|
||||
if not ContextCompressor._is_context_summary_message(msg)
|
||||
)
|
||||
|
||||
|
||||
def test_metadata_summary_decay_also_rehydrates_previous_summary():
|
||||
"""Metadata-only in-process summaries should decay and rehydrate together."""
|
||||
compressor = _compressor(protect_first_n=3)
|
||||
|
||||
msgs = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "metadata-only prior summary",
|
||||
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
||||
},
|
||||
{"role": "user", "content": "new work"},
|
||||
{"role": "assistant", "content": "new answer"},
|
||||
{"role": "user", "content": "tail request"},
|
||||
{"role": "assistant", "content": "tail answer"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")) as mock_call:
|
||||
compressor.compress(msgs)
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "PREVIOUS SUMMARY:" in prompt
|
||||
assert "metadata-only prior summary" in prompt
|
||||
# Grounding may prepend a task-snapshot section; pin the fresh body.
|
||||
assert (compressor._previous_summary or "").endswith("fresh summary")
|
||||
|
||||
|
||||
def test_empty_post_handoff_window_noops_without_summary_call():
|
||||
|
||||
@@ -49,36 +49,8 @@ def _fixed_now():
|
||||
return datetime(2026, 6, 7, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_first_compaction_prompt_contains_dated_anchoring_rule():
|
||||
compressor = _compressor()
|
||||
assert compressor._previous_summary is None
|
||||
with patch.object(hermes_time, "now", _fixed_now), patch(
|
||||
"agent.context_compressor.call_llm", return_value=_response("summary")
|
||||
) as mock_call:
|
||||
compressor._generate_summary(_turns())
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "TEMPORAL ANCHORING" in prompt
|
||||
assert "2026-06-07" in prompt
|
||||
# The worked example must carry the resolved date, proving interpolation.
|
||||
assert "Sent the proposal email to John on 2026-06-07" in prompt
|
||||
# First-compaction path marker still present.
|
||||
assert "TURNS TO SUMMARIZE:" in prompt
|
||||
|
||||
|
||||
def test_iterative_update_prompt_also_contains_anchoring_rule():
|
||||
compressor = _compressor()
|
||||
compressor._previous_summary = "OLD summary body with continuity facts"
|
||||
|
||||
with patch.object(hermes_time, "now", _fixed_now), patch(
|
||||
"agent.context_compressor.call_llm", return_value=_response("updated summary")
|
||||
) as mock_call:
|
||||
compressor._generate_summary(_turns())
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "PREVIOUS SUMMARY:" in prompt
|
||||
assert "TEMPORAL ANCHORING" in prompt
|
||||
assert "2026-06-07" in prompt
|
||||
|
||||
|
||||
def test_clock_failure_omits_rule_but_compaction_still_runs():
|
||||
|
||||
@@ -145,21 +145,6 @@ Vind de bestanden.
|
||||
assert "invented user attribution" in compressor._last_summary_error
|
||||
|
||||
|
||||
def test_zero_user_prompt_anchors_source_language_and_exact_sentinel(compressor):
|
||||
captured_prompt = ""
|
||||
|
||||
def fake_call_llm(**kwargs):
|
||||
nonlocal captured_prompt
|
||||
captured_prompt = kwargs["messages"][0]["content"]
|
||||
return _response(_valid_zero_user_summary())
|
||||
|
||||
with patch("agent.context_compressor.call_llm", side_effect=fake_call_llm):
|
||||
result = compressor._generate_summary(_assistant_tool_turns(0, 2))
|
||||
|
||||
assert result == f"{SUMMARY_PREFIX}\n{_valid_zero_user_summary().strip()}"
|
||||
assert "dominant language of the source turns" in captured_prompt
|
||||
assert _NO_USER_TASK_SENTINEL in captured_prompt
|
||||
assert "Do not write \"User asked:\"" in captured_prompt
|
||||
|
||||
|
||||
def test_zero_user_provenance_survives_iterative_compaction(compressor):
|
||||
@@ -283,94 +268,13 @@ def test_compress_context_todo_snapshot_stays_synthetic_across_two_boundaries(
|
||||
db.close()
|
||||
|
||||
|
||||
def test_continuation_user_marker_is_not_reused_as_real_provenance():
|
||||
todo_snapshot = f"{TODO_INJECTION_HEADER}\n- [ ] inspect. Inspect artifacts (pending)"
|
||||
compressed = [{"role": "assistant", "content": "Scheduled work completed."}]
|
||||
|
||||
_ensure_compressed_has_user_turn(
|
||||
[{"role": "user", "content": todo_snapshot}],
|
||||
compressed,
|
||||
)
|
||||
|
||||
assert compressed[-1] == {
|
||||
"role": "user",
|
||||
"content": COMPRESSION_CONTINUATION_USER_CONTENT,
|
||||
}
|
||||
projected = [{"role": row["role"], "content": row["content"]} for row in compressed]
|
||||
assert ContextCompressor._transcript_has_real_user_turn(projected) is False
|
||||
|
||||
|
||||
def test_continuation_markers_are_not_human_anchors():
|
||||
from agent.conversation_compression import _is_real_user_message
|
||||
|
||||
legacy = (
|
||||
"Continue from the compressed conversation context above. "
|
||||
"This marker exists because the compacted transcript contained "
|
||||
"no preserved user turn."
|
||||
)
|
||||
assert not _is_real_user_message(
|
||||
{"role": "user", "content": COMPRESSION_CONTINUATION_USER_CONTENT}
|
||||
)
|
||||
assert not _is_real_user_message({"role": "user", "content": legacy})
|
||||
|
||||
|
||||
def test_static_fallback_does_not_attribute_synthetic_rows_to_user(compressor):
|
||||
todo_snapshot = f"{TODO_INJECTION_HEADER}\n- [ ] inspect. Inspect artifacts (pending)"
|
||||
fallback = compressor._build_static_fallback_summary(
|
||||
[
|
||||
{"role": "user", "content": todo_snapshot},
|
||||
{
|
||||
"role": "user",
|
||||
"content": COMPRESSION_CONTINUATION_USER_CONTENT,
|
||||
},
|
||||
*_assistant_tool_turns(0, 2),
|
||||
]
|
||||
)
|
||||
|
||||
assert _NO_USER_TASK_SENTINEL in fallback
|
||||
assert "User asked:" not in fallback
|
||||
assert "INTERNAL CONTEXT:" in fallback
|
||||
|
||||
|
||||
def test_zero_user_deterministic_fallback_uses_same_provenance(compressor):
|
||||
messages = _assistant_tool_turns(0, 12)
|
||||
|
||||
with patch.object(compressor, "_generate_summary", return_value=None):
|
||||
result = compressor.compress(messages, current_tokens=90_000)
|
||||
|
||||
handoff = next(
|
||||
message
|
||||
for message in result
|
||||
if message.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
)
|
||||
assert _NO_USER_TASK_SENTINEL in handoff["content"]
|
||||
assert "User asked:" not in handoff["content"]
|
||||
assert handoff[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False
|
||||
|
||||
|
||||
def test_real_user_turn_sets_provenance_true(compressor):
|
||||
messages = [
|
||||
{"role": "user", "content": "Please inspect the build artifacts."},
|
||||
*_assistant_tool_turns(0, 12),
|
||||
]
|
||||
summary = f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nUser asked: 'Please inspect the build artifacts.'"
|
||||
|
||||
with patch.object(compressor, "_generate_summary", return_value=summary):
|
||||
result = compressor.compress(messages, current_tokens=90_000)
|
||||
|
||||
handoff = next(
|
||||
message
|
||||
for message in result
|
||||
if message.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
)
|
||||
assert handoff[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is True
|
||||
|
||||
|
||||
def test_session_boundaries_clear_summary_provenance(compressor):
|
||||
compressor._summary_has_user_turn = False
|
||||
compressor.on_session_reset()
|
||||
assert compressor._summary_has_user_turn is None
|
||||
|
||||
compressor._summary_has_user_turn = True
|
||||
compressor.on_session_end("cron-session", [])
|
||||
assert compressor._summary_has_user_turn is None
|
||||
|
||||
@@ -69,9 +69,6 @@ class StubEngine(ContextEngine):
|
||||
class TestContextEngineABC:
|
||||
"""Verify the ABC enforces the required interface."""
|
||||
|
||||
def test_cannot_instantiate_abc_directly(self):
|
||||
with pytest.raises(TypeError):
|
||||
ContextEngine()
|
||||
|
||||
def test_missing_methods_raises(self):
|
||||
"""A subclass missing required methods cannot be instantiated."""
|
||||
@@ -87,10 +84,6 @@ class TestContextEngineABC:
|
||||
assert isinstance(engine, ContextEngine)
|
||||
assert engine.name == "stub"
|
||||
|
||||
def test_compressor_is_context_engine(self):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, config_context_length=200000)
|
||||
assert isinstance(c, ContextEngine)
|
||||
assert c.name == "compressor"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,16 +93,7 @@ class TestContextEngineABC:
|
||||
class TestDefaults:
|
||||
"""Verify ABC default implementations work correctly."""
|
||||
|
||||
def test_default_tool_schemas_empty(self):
|
||||
engine = StubEngine()
|
||||
# StubEngine overrides this, so test the base via super
|
||||
assert ContextEngine.get_tool_schemas(engine) == []
|
||||
|
||||
def test_default_handle_tool_call_returns_error(self):
|
||||
engine = StubEngine()
|
||||
result = ContextEngine.handle_tool_call(engine, "unknown", {})
|
||||
data = json.loads(result)
|
||||
assert "error" in data
|
||||
|
||||
def test_default_get_status(self):
|
||||
engine = StubEngine()
|
||||
@@ -120,15 +104,6 @@ class TestDefaults:
|
||||
assert status["threshold_tokens"] == 100000
|
||||
assert 0 < status["usage_percent"] <= 100
|
||||
|
||||
def test_default_get_status_clamps_post_compression_sentinel(self):
|
||||
"""After a compression, last_prompt_tokens is the -1 sentinel. get_status
|
||||
must clamp it to 0 rather than export a raw -1 or a negative
|
||||
usage_percent on the transitional turn."""
|
||||
engine = StubEngine()
|
||||
engine.last_prompt_tokens = -1
|
||||
status = engine.get_status()
|
||||
assert status["last_prompt_tokens"] == 0
|
||||
assert status["usage_percent"] >= 0
|
||||
|
||||
def test_on_session_reset(self):
|
||||
engine = StubEngine()
|
||||
@@ -138,9 +113,6 @@ class TestDefaults:
|
||||
assert engine.last_prompt_tokens == 0
|
||||
assert engine.compression_count == 0
|
||||
|
||||
def test_should_compress_preflight_default_false(self):
|
||||
engine = StubEngine()
|
||||
assert engine.should_compress_preflight([]) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -149,19 +121,7 @@ class TestDefaults:
|
||||
|
||||
class TestStubEngine:
|
||||
|
||||
def test_should_compress(self):
|
||||
engine = StubEngine(context_length=100000, threshold_pct=0.50)
|
||||
assert not engine.should_compress(40000)
|
||||
assert engine.should_compress(50000)
|
||||
assert engine.should_compress(60000)
|
||||
|
||||
def test_compress_tracks_count(self):
|
||||
engine = StubEngine()
|
||||
msgs = [{"role": "user", "content": "hello"}]
|
||||
result = engine.compress(msgs)
|
||||
assert result == msgs
|
||||
assert engine._compress_called
|
||||
assert engine.compression_count == 1
|
||||
|
||||
def test_tool_schemas(self):
|
||||
engine = StubEngine()
|
||||
@@ -175,26 +135,7 @@ class TestStubEngine:
|
||||
assert json.loads(result)["ok"] is True
|
||||
assert "stub_search" in engine._tools_called
|
||||
|
||||
def test_update_from_response(self):
|
||||
engine = StubEngine()
|
||||
engine.update_from_response({"prompt_tokens": 1000, "completion_tokens": 200, "total_tokens": 1200})
|
||||
assert engine.last_prompt_tokens == 1000
|
||||
assert engine.last_completion_tokens == 200
|
||||
|
||||
def test_prune_tool_results_only_defaults_to_safe_noop(self):
|
||||
# An engine implementing only the required interface (no prune override)
|
||||
# must inherit the base no-op instead of raising AttributeError: the
|
||||
# agent loop calls prune_tool_results_only() on the active engine after a
|
||||
# tool call whenever full compression does not fire, so every pluggable
|
||||
# ContextEngine reaches this path (see conversation_loop proactive-prune).
|
||||
engine = StubEngine()
|
||||
msgs = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
result, pruned = engine.prune_tool_results_only(msgs, current_tokens=10_000_000)
|
||||
assert pruned == 0
|
||||
assert result is msgs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -242,27 +183,7 @@ class TestPluginContextEngineSlot:
|
||||
assert mgr._context_engine is engine
|
||||
assert mgr._context_engine.name == "stub"
|
||||
|
||||
def test_reject_second_engine(self):
|
||||
from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-lcm")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
engine1 = StubEngine()
|
||||
engine2 = StubEngine()
|
||||
ctx.register_context_engine(engine1)
|
||||
ctx.register_context_engine(engine2) # should be rejected
|
||||
|
||||
assert mgr._context_engine is engine1
|
||||
|
||||
def test_reject_non_engine(self):
|
||||
from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-bad")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
ctx.register_context_engine("not an engine")
|
||||
assert mgr._context_engine is None
|
||||
|
||||
def test_get_plugin_context_engine(self):
|
||||
from hermes_cli.plugins import PluginManager, get_plugin_context_engine
|
||||
@@ -288,20 +209,6 @@ class TestPluginContextEngineDeepCopy:
|
||||
"""Verify that the plugin context engine singleton is deep-copied before
|
||||
mutation in agent_init — regression test for #42449."""
|
||||
|
||||
def test_deepcopy_prevents_shared_mutation(self):
|
||||
"""Deep-copied engine should not propagate mutations back to the singleton."""
|
||||
import copy
|
||||
engine = StubEngine(context_length=1_000_000, threshold_pct=0.20)
|
||||
clone = copy.deepcopy(engine)
|
||||
|
||||
# Mutate the clone (simulating child agent's update_model)
|
||||
clone.context_length = 204800
|
||||
clone.threshold_tokens = 40960
|
||||
|
||||
# Original must be unaffected
|
||||
assert engine.context_length == 1_000_000
|
||||
assert engine.threshold_tokens == 200000 # 1M * 0.20
|
||||
assert clone is not engine
|
||||
|
||||
def test_deepcopy_preserves_engine_name(self):
|
||||
"""Deep-copied engine retains its identity (name property)."""
|
||||
@@ -324,12 +231,6 @@ class TestPluginContextEngineDeepCopy:
|
||||
assert clone.compression_count == 3
|
||||
assert clone is not engine
|
||||
|
||||
def test_no_deepcopy_direct_assignment_would_share_state(self):
|
||||
"""Baseline: without deepcopy, both variables point to the same object."""
|
||||
engine = StubEngine(context_length=1_000_000)
|
||||
direct = engine # no deepcopy — the bug path
|
||||
direct.context_length = 204800
|
||||
assert engine.context_length == 204800 # bug: parent corrupted!
|
||||
|
||||
|
||||
class TestInitAgentDoesNotMutatePluginSingleton:
|
||||
|
||||
@@ -42,53 +42,8 @@ def _bare_agent() -> AIAgent:
|
||||
return agent
|
||||
|
||||
|
||||
def test_transition_runs_full_lifecycle_in_order():
|
||||
"""End → reset → start → carry_over, in that order, when all inputs apply."""
|
||||
events: list[str] = []
|
||||
engine = MagicMock()
|
||||
engine.context_length = 200_000
|
||||
engine.on_session_end.side_effect = lambda *a, **kw: events.append("on_session_end")
|
||||
engine.on_session_reset.side_effect = lambda *a, **kw: events.append("on_session_reset")
|
||||
engine.on_session_start.side_effect = lambda *a, **kw: events.append("on_session_start")
|
||||
engine.carry_over_new_session_context.side_effect = lambda *a, **kw: events.append("carry_over")
|
||||
|
||||
agent = _bare_agent()
|
||||
agent.context_compressor = engine
|
||||
|
||||
agent._transition_context_engine_session(
|
||||
old_session_id="old-sid",
|
||||
new_session_id="new-sid",
|
||||
previous_messages=[{"role": "user", "content": "hi"}],
|
||||
carry_over_context=True,
|
||||
)
|
||||
|
||||
assert events == [
|
||||
"on_session_end",
|
||||
"on_session_reset",
|
||||
"on_session_start",
|
||||
"carry_over",
|
||||
]
|
||||
|
||||
|
||||
def test_transition_passes_conversation_id_from_gateway_session_key():
|
||||
"""on_session_start receives ``conversation_id`` from ``_gateway_session_key``."""
|
||||
engine = MagicMock()
|
||||
engine.context_length = 200_000
|
||||
captured: dict = {}
|
||||
engine.on_session_start.side_effect = lambda sid, **kw: captured.update(kw)
|
||||
|
||||
agent = _bare_agent()
|
||||
agent.context_compressor = engine
|
||||
|
||||
agent._transition_context_engine_session(
|
||||
old_session_id="old-sid",
|
||||
new_session_id="new-sid",
|
||||
previous_messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert captured.get("conversation_id") == "agent:main:telegram:dm:42"
|
||||
assert captured.get("old_session_id") == "old-sid"
|
||||
assert captured.get("platform") == "telegram"
|
||||
|
||||
|
||||
def test_transition_skips_optional_hooks_when_engine_lacks_them():
|
||||
@@ -124,39 +79,8 @@ def test_transition_skips_optional_hooks_when_engine_lacks_them():
|
||||
assert kw.get("old_session_id") == "old"
|
||||
|
||||
|
||||
def test_reset_session_state_delegates_to_transition_when_args_provided():
|
||||
"""``reset_session_state(previous_messages=..., old_session_id=...)`` fires full lifecycle."""
|
||||
engine = MagicMock()
|
||||
engine.context_length = 100_000
|
||||
|
||||
agent = _bare_agent()
|
||||
agent.context_compressor = engine
|
||||
|
||||
agent.reset_session_state(
|
||||
previous_messages=[{"role": "user", "content": "hi"}],
|
||||
old_session_id="old-sid",
|
||||
)
|
||||
|
||||
assert engine.on_session_end.called
|
||||
assert engine.on_session_reset.called
|
||||
assert engine.on_session_start.called
|
||||
# No carry_over_context, so carry_over hook NOT called.
|
||||
assert not engine.carry_over_new_session_context.called
|
||||
|
||||
|
||||
def test_reset_session_state_default_call_only_resets():
|
||||
"""Bare ``reset_session_state()`` still only resets the engine (no end/start)."""
|
||||
engine = MagicMock()
|
||||
engine.context_length = 100_000
|
||||
|
||||
agent = _bare_agent()
|
||||
agent.context_compressor = engine
|
||||
|
||||
agent.reset_session_state()
|
||||
|
||||
assert engine.on_session_reset.called
|
||||
assert not engine.on_session_end.called
|
||||
assert not engine.on_session_start.called
|
||||
|
||||
|
||||
def test_reset_session_state_rebinds_builtin_compressor_after_session_switch(tmp_path, monkeypatch):
|
||||
@@ -236,57 +160,8 @@ def test_update_from_response_forwards_canonical_cache_buckets():
|
||||
assert usage_dict["output_tokens"] == 500
|
||||
|
||||
|
||||
def test_discover_context_engines_includes_plugin_registered_engines(monkeypatch):
|
||||
"""Plugin-registered context engines appear in the ``hermes plugins`` picker."""
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
fake_repo = lambda: [("compressor", "built-in", True)]
|
||||
|
||||
class FakePluginEngine:
|
||||
name = "lcm"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.context_engine.discover_context_engines",
|
||||
fake_repo,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.discover_plugins",
|
||||
lambda *_a, **_kw: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.get_plugin_context_engine",
|
||||
lambda: FakePluginEngine(),
|
||||
)
|
||||
|
||||
engines = plugins_cmd._discover_context_engines()
|
||||
names = [n for n, _desc in engines]
|
||||
assert "compressor" in names
|
||||
assert "lcm" in names
|
||||
|
||||
|
||||
def test_discover_context_engines_dedupes_by_name(monkeypatch):
|
||||
"""Repo-shipped engine wins when name collides with a plugin-registered one."""
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
class FakePluginEngine:
|
||||
name = "compressor" # same name as repo-shipped
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.context_engine.discover_context_engines",
|
||||
lambda: [("compressor", "built-in compressor", True)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.discover_plugins",
|
||||
lambda *_a, **_kw: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.get_plugin_context_engine",
|
||||
lambda: FakePluginEngine(),
|
||||
)
|
||||
|
||||
engines = plugins_cmd._discover_context_engines()
|
||||
# Only one entry — the repo-shipped one. Description is preserved.
|
||||
assert engines == [("compressor", "built-in compressor")]
|
||||
|
||||
|
||||
def test_engine_collector_forwards_register_command_to_plugin_manager():
|
||||
@@ -316,15 +191,3 @@ def test_engine_collector_forwards_register_command_to_plugin_manager():
|
||||
manager._plugin_commands.pop("my-lcm-test-cmd", None)
|
||||
|
||||
|
||||
def test_engine_collector_rejects_builtin_command_conflicts():
|
||||
"""Context engine cannot shadow built-in slash commands like /help."""
|
||||
from plugins.context_engine import _EngineCollector
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
|
||||
collector = _EngineCollector(engine_name="my-lcm")
|
||||
collector.register_command("help", lambda *_: "shadow")
|
||||
|
||||
manager = get_plugin_manager()
|
||||
# Must NOT have overwritten / registered against built-in /help.
|
||||
assert "help" not in manager._plugin_commands or \
|
||||
manager._plugin_commands["help"].get("plugin") != "context-engine:my-lcm"
|
||||
|
||||
@@ -63,30 +63,10 @@ HISTORY = [{"role": "user", "content": "hello"}]
|
||||
|
||||
# -- ABC default -----------------------------------------------------------
|
||||
|
||||
def test_default_select_context_is_noop():
|
||||
"""The base implementation returns None (no replacement)."""
|
||||
engine = _MinimalEngine()
|
||||
assert (
|
||||
engine.select_context(
|
||||
REQUEST,
|
||||
conversation_messages=HISTORY,
|
||||
incoming_message=HISTORY[-1],
|
||||
budget_tokens=0,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
# -- Host call site: _apply_context_engine_selection -----------------------
|
||||
|
||||
def test_none_return_leaves_request_unchanged():
|
||||
"""An engine returning None falls through to the assembled request."""
|
||||
engine = _MinimalEngine() # default select_context -> None
|
||||
agent = _agent_with(engine)
|
||||
out = _apply_context_engine_selection(
|
||||
agent, REQUEST, HISTORY, HISTORY[-1], logger=MagicMock()
|
||||
)
|
||||
assert out is REQUEST
|
||||
|
||||
|
||||
def test_base_noop_select_context_is_short_circuited_not_called():
|
||||
@@ -117,102 +97,18 @@ def test_base_noop_select_context_is_short_circuited_not_called():
|
||||
assert not logger.warning.called
|
||||
|
||||
|
||||
def test_builtin_compressor_inherits_base_select_context():
|
||||
"""The built-in ContextCompressor must NOT implement the new verbs.
|
||||
|
||||
Guards the default-path byte-identity contract: if someone overrides
|
||||
``select_context`` / ``on_turn_complete`` on ContextCompressor, the host
|
||||
short-circuits no longer skip it and the default request pipeline gains a
|
||||
per-request call — update this pin only together with that decision.
|
||||
"""
|
||||
from agent.context_compressor import ContextCompressor
|
||||
|
||||
assert "select_context" not in ContextCompressor.__dict__
|
||||
assert "on_turn_complete" not in ContextCompressor.__dict__
|
||||
|
||||
|
||||
def test_missing_hook_leaves_request_unchanged():
|
||||
"""An engine without select_context (older/stub base) is a no-op."""
|
||||
engine = object() # no select_context attribute
|
||||
agent = _agent_with(engine)
|
||||
out = _apply_context_engine_selection(
|
||||
agent, REQUEST, HISTORY, HISTORY[-1], logger=MagicMock()
|
||||
)
|
||||
assert out is REQUEST
|
||||
|
||||
|
||||
def test_no_engine_leaves_request_unchanged():
|
||||
agent = MagicMock()
|
||||
agent.session_id = "test-session"
|
||||
agent.context_compressor = None
|
||||
out = _apply_context_engine_selection(
|
||||
agent, REQUEST, HISTORY, HISTORY[-1], logger=MagicMock()
|
||||
)
|
||||
assert out is REQUEST
|
||||
|
||||
|
||||
def test_valid_list_replaces_request():
|
||||
"""A valid list of dicts replaces the request messages for this call."""
|
||||
replacement = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "routed-context"},
|
||||
]
|
||||
|
||||
class _Engine(_MinimalEngine):
|
||||
def select_context(self, request_messages, **kwargs):
|
||||
return replacement
|
||||
|
||||
agent = _agent_with(_Engine())
|
||||
out = _apply_context_engine_selection(
|
||||
agent, REQUEST, HISTORY, HISTORY[-1], logger=MagicMock()
|
||||
)
|
||||
assert out is replacement
|
||||
|
||||
|
||||
def test_exception_fails_open():
|
||||
"""A raising hook is swallowed; the unmodified request is used."""
|
||||
|
||||
class _Engine(_MinimalEngine):
|
||||
def select_context(self, request_messages, **kwargs):
|
||||
raise RuntimeError("backend offline")
|
||||
|
||||
logger = MagicMock()
|
||||
agent = _agent_with(_Engine())
|
||||
out = _apply_context_engine_selection(
|
||||
agent, REQUEST, HISTORY, HISTORY[-1], logger=logger
|
||||
)
|
||||
assert out is REQUEST
|
||||
assert logger.warning.called
|
||||
|
||||
|
||||
def test_non_list_return_is_ignored():
|
||||
"""A non-list return value is rejected and logged, request unchanged."""
|
||||
|
||||
class _Engine(_MinimalEngine):
|
||||
def select_context(self, request_messages, **kwargs):
|
||||
return {"role": "user", "content": "oops not a list"}
|
||||
|
||||
logger = MagicMock()
|
||||
agent = _agent_with(_Engine())
|
||||
out = _apply_context_engine_selection(
|
||||
agent, REQUEST, HISTORY, HISTORY[-1], logger=logger
|
||||
)
|
||||
assert out is REQUEST
|
||||
assert logger.warning.called
|
||||
|
||||
|
||||
def test_list_of_non_dicts_is_ignored():
|
||||
"""A list that isn't all dicts is rejected, request unchanged."""
|
||||
|
||||
class _Engine(_MinimalEngine):
|
||||
def select_context(self, request_messages, **kwargs):
|
||||
return ["not", "dicts"]
|
||||
|
||||
agent = _agent_with(_Engine())
|
||||
out = _apply_context_engine_selection(
|
||||
agent, REQUEST, HISTORY, HISTORY[-1], logger=MagicMock()
|
||||
)
|
||||
assert out is REQUEST
|
||||
|
||||
|
||||
def test_empty_list_keeps_original_request():
|
||||
@@ -295,21 +191,6 @@ def test_persisted_history_not_mutated():
|
||||
|
||||
# -- cache-stability + downstream-sanitizer contract -----------------------
|
||||
|
||||
def test_noop_preserves_request_byte_stable_for_cache():
|
||||
"""No-op default must leave the request byte-identical.
|
||||
|
||||
Prompt-cache stability is a host invariant (AGENTS.md): the hook runs
|
||||
before cache-control, so a no-op engine must not perturb the list —
|
||||
otherwise cache breakpoints would shift for every existing engine. The
|
||||
host returns the *same object*, so cache-control sees identical input.
|
||||
"""
|
||||
snapshot = [dict(m) for m in REQUEST]
|
||||
agent = _agent_with(_MinimalEngine()) # default select_context -> None
|
||||
out = _apply_context_engine_selection(
|
||||
agent, REQUEST, HISTORY, HISTORY[-1], logger=MagicMock()
|
||||
)
|
||||
assert out is REQUEST # same object -> byte-stable for cache-control
|
||||
assert REQUEST == snapshot # unperturbed
|
||||
|
||||
|
||||
def test_role_unusual_replacement_passed_through_for_downstream_sanitizers():
|
||||
@@ -342,9 +223,6 @@ def test_role_unusual_replacement_passed_through_for_downstream_sanitizers():
|
||||
|
||||
# -- on_turn_complete (post-turn observation) ------------------------------
|
||||
|
||||
def test_default_on_turn_complete_is_noop():
|
||||
"""The base on_turn_complete returns None and does nothing."""
|
||||
assert _MinimalEngine().on_turn_complete(HISTORY, usage=None) is None
|
||||
|
||||
|
||||
def test_on_turn_complete_called_with_snapshot_and_meta():
|
||||
@@ -369,32 +247,7 @@ def test_on_turn_complete_called_with_snapshot_and_meta():
|
||||
assert captured["kwargs"]["api_call_count"] == 1
|
||||
|
||||
|
||||
def test_on_turn_complete_base_noop_is_skipped():
|
||||
"""An engine that only inherits the base no-op is handled safely.
|
||||
|
||||
The helper short-circuits the base implementation (so non-implementing
|
||||
engines pay nothing), and in any case must not raise.
|
||||
"""
|
||||
agent = _agent_with(_MinimalEngine()) # inherits base on_turn_complete
|
||||
_notify_context_engine_turn_complete(agent, HISTORY, logger=MagicMock())
|
||||
|
||||
|
||||
def test_on_turn_complete_fails_open():
|
||||
"""A raising observation hook is swallowed and logged."""
|
||||
|
||||
class _Engine(_MinimalEngine):
|
||||
def on_turn_complete(self, messages, usage=None, **kwargs):
|
||||
raise RuntimeError("indexing backend down")
|
||||
|
||||
logger = MagicMock()
|
||||
agent = _agent_with(_Engine())
|
||||
_notify_context_engine_turn_complete(agent, HISTORY, logger=logger)
|
||||
assert logger.warning.called
|
||||
|
||||
|
||||
def test_on_turn_complete_missing_engine_is_safe():
|
||||
agent = MagicMock()
|
||||
agent.session_id = "s"
|
||||
agent.context_compressor = None
|
||||
# No engine -> silent return, no raise.
|
||||
_notify_context_engine_turn_complete(agent, HISTORY, logger=MagicMock())
|
||||
|
||||
@@ -72,58 +72,10 @@ def test_parse_typed_references_ignores_emails_and_handles():
|
||||
assert refs[2].target == "2"
|
||||
|
||||
|
||||
def test_parse_references_strips_trailing_punctuation():
|
||||
from agent.context_references import parse_context_references
|
||||
|
||||
refs = parse_context_references(
|
||||
"review @file:README.md, then see (@url:https://example.com/docs)."
|
||||
)
|
||||
|
||||
assert [ref.kind for ref in refs] == ["file", "url"]
|
||||
assert refs[0].target == "README.md"
|
||||
assert refs[1].target == "https://example.com/docs"
|
||||
|
||||
|
||||
def test_parse_quoted_references_with_spaces_and_preserve_unquoted_ranges():
|
||||
from agent.context_references import parse_context_references
|
||||
|
||||
refs = parse_context_references(
|
||||
'review @file:"C:\\Users\\Simba\\My Project\\main.py":7-9 '
|
||||
'and @folder:"docs and specs" plus @file:src/main.py:1-2'
|
||||
)
|
||||
|
||||
assert [ref.kind for ref in refs] == ["file", "folder", "file"]
|
||||
assert refs[0].target == r"C:\Users\Simba\My Project\main.py"
|
||||
assert refs[0].line_start == 7
|
||||
assert refs[0].line_end == 9
|
||||
assert refs[1].target == "docs and specs"
|
||||
assert refs[2].target == "src/main.py"
|
||||
assert refs[2].line_start == 1
|
||||
assert refs[2].line_end == 2
|
||||
|
||||
|
||||
def test_expand_file_range_and_folder_listing(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
result = preprocess_context_references(
|
||||
"Review @file:src/main.py:1-2 and @folder:src/",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
# The typed `@` tokens stay in the prose — clients render each one as an
|
||||
# inline chip where the user put it, rather than a detached list.
|
||||
assert result.message.startswith("Review @file:src/main.py:1-2 and @folder:src/")
|
||||
assert "--- Attached Context ---" in result.message
|
||||
assert "def alpha():" in result.message
|
||||
assert "return 'changed'" in result.message
|
||||
assert "def beta():" not in result.message
|
||||
assert "src/" in result.message
|
||||
assert "main.py" in result.message
|
||||
assert "helper.py" in result.message
|
||||
assert result.injected_tokens > 0
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_folder_listing_falls_back_when_rg_is_blocked(sample_repo: Path):
|
||||
@@ -151,46 +103,8 @@ def test_folder_listing_falls_back_when_rg_is_blocked(sample_repo: Path):
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_expand_quoted_file_reference_with_spaces(tmp_path: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
workspace = tmp_path / "repo"
|
||||
folder = workspace / "docs and specs"
|
||||
folder.mkdir(parents=True)
|
||||
file_path = folder / "release notes.txt"
|
||||
file_path.write_text("line 1\nline 2\nline 3\n", encoding="utf-8")
|
||||
|
||||
result = preprocess_context_references(
|
||||
'Review @file:"docs and specs/release notes.txt":2-3',
|
||||
cwd=workspace,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert result.message.startswith("Review")
|
||||
assert "line 1" not in result.message
|
||||
assert "line 2" in result.message
|
||||
assert "line 3" in result.message
|
||||
assert "release notes.txt" in result.message
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_expand_git_diff_staged_and_log(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
result = preprocess_context_references(
|
||||
"Inspect @diff and @staged and @git:1",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "git diff" in result.message
|
||||
assert "git diff --staged" in result.message
|
||||
assert "git log -1 -p" in result.message
|
||||
assert "initial" in result.message
|
||||
assert "return 'changed'" in result.message
|
||||
assert "VALUE = 2" in result.message
|
||||
|
||||
|
||||
def test_missing_file_becomes_warning(sample_repo: Path):
|
||||
@@ -207,153 +121,18 @@ def test_missing_file_becomes_warning(sample_repo: Path):
|
||||
assert "not found" in result.message.lower()
|
||||
|
||||
|
||||
def test_binary_file_yields_actionable_block_not_a_dead_warning(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
result = preprocess_context_references(
|
||||
"Check @file:blob.bin",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
# The whole point: a binary attachment must NOT degrade into a discouraging
|
||||
# warning that makes the model give up — it gets an actionable content block.
|
||||
assert not result.warnings
|
||||
assert "blob.bin" in result.message
|
||||
assert "binary" in result.message.lower()
|
||||
assert "not supported" not in result.message.lower()
|
||||
# And it must point the agent at the file so it can act on it with tools.
|
||||
assert str(sample_repo / "blob.bin") in result.message
|
||||
|
||||
|
||||
def test_soft_budget_warns_and_hard_budget_refuses(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
soft = preprocess_context_references(
|
||||
"Check @file:src/main.py",
|
||||
cwd=sample_repo,
|
||||
context_length=100,
|
||||
)
|
||||
assert soft.expanded
|
||||
assert any("25%" in warning for warning in soft.warnings)
|
||||
|
||||
hard = preprocess_context_references(
|
||||
"Check @file:src/main.py and @file:README.md",
|
||||
cwd=sample_repo,
|
||||
context_length=20,
|
||||
)
|
||||
assert not hard.expanded
|
||||
assert hard.blocked
|
||||
assert "@file:src/main.py" in hard.message
|
||||
assert any("50%" in warning for warning in hard.warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_url_expansion_uses_fetcher(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references_async
|
||||
|
||||
async def fake_fetch(url: str) -> str:
|
||||
assert url == "https://example.com/spec"
|
||||
return "# Spec\n\nImportant details."
|
||||
|
||||
result = await preprocess_context_references_async(
|
||||
"Use @url:https://example.com/spec",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
url_fetcher=fake_fetch,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "Important details." in result.message
|
||||
assert result.injected_tokens > 0
|
||||
|
||||
|
||||
def test_sync_url_expansion_uses_async_fetcher(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
async def fake_fetch(url: str) -> str:
|
||||
await asyncio.sleep(0)
|
||||
return f"Content for {url}"
|
||||
|
||||
result = preprocess_context_references(
|
||||
"Use @url:https://example.com/spec",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
url_fetcher=fake_fetch,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "Content for https://example.com/spec" in result.message
|
||||
|
||||
|
||||
def test_restricts_paths_to_allowed_root(tmp_path: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "notes.txt").write_text("inside\n", encoding="utf-8")
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("outside\n", encoding="utf-8")
|
||||
|
||||
result = preprocess_context_references(
|
||||
"read @file:../secret.txt and @file:notes.txt",
|
||||
cwd=workspace,
|
||||
context_length=100_000,
|
||||
allowed_root=workspace,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "```\noutside\n```" not in result.message
|
||||
assert "inside" in result.message
|
||||
assert any("outside the allowed workspace" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
def test_defaults_allowed_root_to_cwd(tmp_path: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("outside\n", encoding="utf-8")
|
||||
|
||||
result = preprocess_context_references(
|
||||
f"read @file:{secret}",
|
||||
cwd=workspace,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "```\noutside\n```" not in result.message
|
||||
assert any("outside the allowed workspace" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocks_sensitive_home_and_hermes_paths(tmp_path: Path, monkeypatch):
|
||||
from agent.context_references import preprocess_context_references_async
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
|
||||
hermes_env = tmp_path / ".hermes" / ".env"
|
||||
hermes_env.parent.mkdir(parents=True)
|
||||
hermes_env.write_text("API_KEY=super-secret\n", encoding="utf-8")
|
||||
|
||||
ssh_key = tmp_path / ".ssh" / "id_rsa"
|
||||
ssh_key.parent.mkdir(parents=True)
|
||||
ssh_key.write_text("PRIVATE-KEY\n", encoding="utf-8")
|
||||
|
||||
result = await preprocess_context_references_async(
|
||||
"read @file:.hermes/.env and @file:.ssh/id_rsa",
|
||||
cwd=tmp_path,
|
||||
allowed_root=tmp_path,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "API_KEY=super-secret" not in result.message
|
||||
assert "PRIVATE-KEY" not in result.message
|
||||
assert any("sensitive credential" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -22,55 +22,7 @@ class CopilotACPClientSafetyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = CopilotACPClient(acp_cwd="/tmp")
|
||||
|
||||
def test_extracted_tool_calls_match_openai_sdk_shape(self) -> None:
|
||||
tool_response = (
|
||||
"I'll inspect that.\n"
|
||||
"<tool_call>"
|
||||
'{"id":"call_read","type":"function",'
|
||||
'"function":{"name":"read_file","arguments":"{\\"path\\":\\"README.md\\"}"}}'
|
||||
"</tool_call>"
|
||||
)
|
||||
|
||||
with patch.object(self.client, "_run_prompt", return_value=(tool_response, "")):
|
||||
response = self.client._create_chat_completion(
|
||||
model="copilot-acp",
|
||||
messages=[{"role": "user", "content": "read README.md"}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "parameters": {}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
self.assertEqual(choice.finish_reason, "tool_calls")
|
||||
tool_call = choice.message.tool_calls[0]
|
||||
self.assertEqual(tool_call.id, "call_read")
|
||||
self.assertEqual(tool_call.function.name, "read_file")
|
||||
self.assertEqual(
|
||||
json.loads(tool_call.function.arguments),
|
||||
{"path": "README.md"},
|
||||
)
|
||||
self.assertEqual(dict(tool_call)["id"], "call_read")
|
||||
self.assertEqual(dict(tool_call.function)["name"], "read_file")
|
||||
self.assertEqual(choice.message.content, "I'll inspect that.")
|
||||
|
||||
def test_stream_true_returns_iterable_text_chunks(self) -> None:
|
||||
with patch.object(self.client, "_run_prompt", return_value=("Hello from ACP", "")):
|
||||
stream = self.client._create_chat_completion(
|
||||
model="copilot-acp",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
stream=True,
|
||||
)
|
||||
|
||||
chunks = list(stream)
|
||||
self.assertEqual(len(chunks), 2)
|
||||
self.assertEqual(chunks[0].choices[0].delta.content, "Hello from ACP")
|
||||
self.assertIsNone(chunks[0].choices[0].delta.tool_calls)
|
||||
self.assertEqual(chunks[0].choices[0].finish_reason, "stop")
|
||||
self.assertEqual(chunks[1].choices, [])
|
||||
self.assertEqual(chunks[1].usage.total_tokens, 0)
|
||||
|
||||
def test_stream_true_preserves_tool_call_deltas(self) -> None:
|
||||
tool_response = (
|
||||
@@ -102,30 +54,6 @@ class CopilotACPClientSafetyTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(chunks[1].choices, [])
|
||||
|
||||
def test_timeout_object_is_coerced_for_streaming_requests(self) -> None:
|
||||
captured: dict[str, float] = {}
|
||||
|
||||
def fake_run_prompt(prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:
|
||||
captured["timeout"] = timeout_seconds
|
||||
return "ok", ""
|
||||
|
||||
timeout = type(
|
||||
"TimeoutLike",
|
||||
(),
|
||||
{"read": 12.0, "write": 5.0, "connect": 3.0, "pool": 1.0},
|
||||
)()
|
||||
|
||||
with patch.object(self.client, "_run_prompt", side_effect=fake_run_prompt):
|
||||
list(
|
||||
self.client._create_chat_completion(
|
||||
model="copilot-acp",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
timeout=timeout,
|
||||
stream=True,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(captured["timeout"], 12.0)
|
||||
|
||||
def _dispatch(self, message: dict, *, cwd: str) -> dict:
|
||||
process = _FakeProcess()
|
||||
@@ -141,43 +69,7 @@ class CopilotACPClientSafetyTests(unittest.TestCase):
|
||||
self.assertTrue(payload)
|
||||
return json.loads(payload)
|
||||
|
||||
def test_request_permission_is_not_auto_allowed(self) -> None:
|
||||
response = self._dispatch(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "session/request_permission",
|
||||
"params": {},
|
||||
},
|
||||
cwd="/tmp",
|
||||
)
|
||||
|
||||
outcome = (((response.get("result") or {}).get("outcome") or {}).get("outcome"))
|
||||
self.assertEqual(outcome, "cancelled")
|
||||
|
||||
def test_read_text_file_blocks_internal_hermes_hub_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
home = Path(tmpdir) / "home"
|
||||
blocked = home / ".hermes" / "skills" / ".hub" / "index-cache" / "entry.json"
|
||||
blocked.parent.mkdir(parents=True, exist_ok=True)
|
||||
blocked.write_text('{"token":"sk-test-secret-1234567890"}')
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"HOME": str(home), "HERMES_HOME": str(home / ".hermes")},
|
||||
clear=False,
|
||||
):
|
||||
response = self._dispatch(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "fs/read_text_file",
|
||||
"params": {"path": str(blocked)},
|
||||
},
|
||||
cwd=str(home),
|
||||
)
|
||||
|
||||
self.assertIn("error", response)
|
||||
|
||||
def test_read_text_file_redacts_sensitive_content(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -241,72 +133,7 @@ class CopilotACPClientSafetyTests(unittest.TestCase):
|
||||
self.assertIn("中文标题", content)
|
||||
self.assertIn("em dash —", content)
|
||||
|
||||
def test_fs_write_text_file_encodes_as_utf8(self) -> None:
|
||||
"""Regression for #18637 (bug 2): fs/write_text_file used
|
||||
``path.write_text()`` with no explicit encoding, so on non-UTF-8
|
||||
locales the Copilot write tool could not emit code/config files
|
||||
containing any char outside the platform codec."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
target = root / "out.md"
|
||||
payload = "# 中文标题\nem dash — here\n"
|
||||
|
||||
original_write_text = Path.write_text
|
||||
|
||||
def strict_write_text(
|
||||
self, data, encoding=None, errors=None, **kwargs
|
||||
):
|
||||
if self == target and encoding != "utf-8":
|
||||
raise UnicodeEncodeError(
|
||||
"gbk", data, 0, 1, "illegal multibyte sequence"
|
||||
)
|
||||
return original_write_text(
|
||||
self, data, encoding=encoding, errors=errors, **kwargs
|
||||
)
|
||||
|
||||
with patch.object(Path, "write_text", strict_write_text):
|
||||
response = self._dispatch(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 11,
|
||||
"method": "fs/write_text_file",
|
||||
"params": {
|
||||
"path": str(target),
|
||||
"content": payload,
|
||||
},
|
||||
},
|
||||
cwd=str(root),
|
||||
)
|
||||
|
||||
self.assertNotIn("error", response)
|
||||
self.assertEqual(target.read_text(encoding="utf-8"), payload)
|
||||
|
||||
def test_write_text_file_reuses_write_denylist(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
home = Path(tmpdir) / "home"
|
||||
target = home / ".ssh" / "id_rsa"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with patch(
|
||||
"agent.copilot_acp_client.get_write_denied_error",
|
||||
return_value="Write denied: protected",
|
||||
create=True,
|
||||
):
|
||||
response = self._dispatch(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "fs/write_text_file",
|
||||
"params": {
|
||||
"path": str(target),
|
||||
"content": "fake-private-key",
|
||||
},
|
||||
},
|
||||
cwd=str(home),
|
||||
)
|
||||
|
||||
self.assertIn("error", response)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_write_text_file_respects_safe_root(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,18 +11,6 @@ from agent.credential_pool import (
|
||||
)
|
||||
|
||||
|
||||
def test_manual_anthropic_oat_normalized_to_oauth():
|
||||
# Pool auth_type gates OAuth-only resolver and refresh paths.
|
||||
entry = PooledCredential.from_dict(
|
||||
"anthropic",
|
||||
{
|
||||
"label": "MainKey",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"access_token": "sk-ant-oat-EXAMPLE",
|
||||
},
|
||||
)
|
||||
assert entry.auth_type == AUTH_TYPE_OAUTH
|
||||
|
||||
|
||||
def test_anthropic_real_api_key_unchanged():
|
||||
@@ -33,41 +21,10 @@ def test_anthropic_real_api_key_unchanged():
|
||||
assert entry.auth_type == AUTH_TYPE_API_KEY
|
||||
|
||||
|
||||
def test_anthropic_admin_key_unchanged():
|
||||
entry = PooledCredential.from_dict(
|
||||
"anthropic",
|
||||
{"auth_type": "api_key", "access_token": "sk-ant-admin-EXAMPLE"},
|
||||
)
|
||||
assert entry.auth_type == AUTH_TYPE_API_KEY
|
||||
|
||||
|
||||
def test_non_anthropic_provider_unchanged():
|
||||
entry = PooledCredential.from_dict(
|
||||
"openrouter",
|
||||
{"auth_type": "api_key", "access_token": "sk-ant-oat-WHATEVER"},
|
||||
)
|
||||
assert entry.auth_type == AUTH_TYPE_API_KEY
|
||||
|
||||
|
||||
def test_add_entry_normalizes_before_persisting(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
pool = CredentialPool("anthropic", [])
|
||||
entry = pool.add_entry(PooledCredential(
|
||||
provider="anthropic",
|
||||
id="manual-oat",
|
||||
label="Manual setup token",
|
||||
auth_type=AUTH_TYPE_API_KEY,
|
||||
priority=0,
|
||||
source="manual",
|
||||
access_token="sk-ant-oat-manual-entry",
|
||||
))
|
||||
|
||||
persisted = json.loads((hermes_home / "auth.json").read_text())
|
||||
assert entry.auth_type == AUTH_TYPE_OAUTH
|
||||
assert persisted["credential_pool"]["anthropic"][0]["auth_type"] == AUTH_TYPE_OAUTH
|
||||
|
||||
|
||||
def test_load_heals_legacy_row_and_exposes_it_to_resolver(tmp_path, monkeypatch):
|
||||
@@ -106,33 +63,3 @@ def test_load_heals_legacy_row_and_exposes_it_to_resolver(tmp_path, monkeypatch)
|
||||
assert resolve_anthropic_token() == token
|
||||
|
||||
|
||||
def test_profile_global_fallback_normalizes_in_memory_without_writing(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
global_root = tmp_path / ".hermes"
|
||||
global_root.mkdir()
|
||||
profile_home = global_root / "profiles" / "coder"
|
||||
profile_home.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
||||
token = "sk-ant-oat-global-fallback"
|
||||
global_auth = global_root / "auth.json"
|
||||
global_auth.write_text(json.dumps({
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"anthropic": [{
|
||||
"id": "global-oat",
|
||||
"label": "Global setup token",
|
||||
"auth_type": AUTH_TYPE_API_KEY,
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": token,
|
||||
}],
|
||||
},
|
||||
}))
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
entry = load_pool("anthropic").entries()[0]
|
||||
persisted = json.loads(global_auth.read_text())
|
||||
assert entry.auth_type == AUTH_TYPE_OAUTH
|
||||
assert persisted["credential_pool"]["anthropic"][0]["auth_type"] == AUTH_TYPE_API_KEY
|
||||
assert not (profile_home / "auth.json").exists()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user