Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- tests/agent/conftest.py +39 -0
- tests/agent/test_account_usage.py +229 -0
- tests/agent/test_anthropic_borrowed_row_authority.py +281 -0
- tests/agent/test_anthropic_client_auth.py +79 -0
- tests/agent/test_anthropic_oauth_stress.py +387 -0
- tests/agent/test_anthropic_output_field_leak.py +76 -0
- tests/agent/test_anthropic_truncation_continuation.py +97 -0
- tests/agent/test_api_content_row_addressed_backfill.py +238 -0
- tests/agent/test_arcee_trinity_overrides.py +189 -0
- tests/agent/test_aux_affordable_402_retry.py +197 -0
- tests/agent/test_auxiliary_anthropic_pool_fallback_regression.py +85 -0
- tests/agent/test_auxiliary_client_azure_foundry.py +325 -0
- tests/agent/test_auxiliary_concurrency.py +403 -0
- tests/agent/test_auxiliary_named_custom_providers.py +480 -0
- tests/agent/test_auxiliary_transient_retry.py +62 -0
- tests/agent/test_azure_identity_adapter.py +496 -0
- tests/agent/test_background_review_input_budget.py +239 -0
- tests/agent/test_background_review_summary.py +112 -0
- tests/agent/test_background_review_tool_call_guard.py +125 -0
- tests/agent/test_bedrock_empty_text_blocks.py +78 -0
- tests/agent/test_billing_unverified_carrythrough.py +171 -0
- tests/agent/test_billing_view.py +346 -0
- tests/agent/test_bitwarden_secrets.py +523 -0
- tests/agent/test_bounded_response.py +113 -0
- tests/agent/test_budget_reasoning_details_exclusion.py +125 -0
- tests/agent/test_builtin_memory_disabled_surface.py +315 -0
- tests/agent/test_cache_disabled_on_stubs.py +382 -0
- tests/agent/test_chat_completion_helpers_provider_sort.py +13 -0
- tests/agent/test_codex_app_server_event_bridge.py +401 -0
- tests/agent/test_codex_app_server_lifecycle.py +82 -0
- tests/agent/test_codex_echo_ownership.py +50 -0
- tests/agent/test_codex_no_tools_nonetype.py +132 -0
- tests/agent/test_command_secret_source.py +223 -0
- tests/agent/test_compaction_ops_notification_anchor.py +103 -0
- tests/agent/test_compaction_redaction_boundaries.py +223 -0
- tests/agent/test_compression_attempt_lifecycle.py +360 -0
- tests/agent/test_compression_boundary.py +145 -0
- tests/agent/test_compression_concurrent_fork.py +2403 -0
- tests/agent/test_compression_interrupt_protection.py +175 -0
- tests/agent/test_compression_max_attempts_config.py +93 -0
- tests/agent/test_compression_split_failure_cooldown.py +56 -0
- tests/agent/test_content_policy_blocked.py +142 -0
- tests/agent/test_context_compressor_structural_backoff.py +161 -0
- tests/agent/test_context_compressor_summary_continuity.py +461 -0
- tests/agent/test_context_compressor_zero_user_provenance.py +459 -0
- tests/agent/test_context_refs_concurrent.py +90 -0
- tests/agent/test_conversation_loop_interpreter_shutdown.py +68 -0
- tests/agent/test_copilot_initiator.py +139 -0
- tests/agent/test_create_openai_client_disables_sdk_retries.py +78 -0
- tests/agent/test_create_openai_client_reuse.py +347 -0
tests/agent/conftest.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fast-path fixtures shared across tests/agent/.
|
| 2 |
+
|
| 3 |
+
Many tests in this directory exercise the retry/backoff paths in the
|
| 4 |
+
agent loop. Production code uses ``jittered_backoff(base_delay=5.0)``
|
| 5 |
+
with a ``while time.time() < sleep_end`` loop — a single retry test
|
| 6 |
+
spends 5+ seconds of real wall-clock time on backoff waits.
|
| 7 |
+
|
| 8 |
+
Mocking ``jittered_backoff`` to return 0.0 collapses the while-loop
|
| 9 |
+
to a no-op (``time.time() < time.time() + 0`` is false immediately),
|
| 10 |
+
which handles the most common case without touching ``time.sleep``.
|
| 11 |
+
|
| 12 |
+
We deliberately DO NOT mock ``time.sleep`` here — some tests
|
| 13 |
+
(test_interrupt_propagation, test_primary_runtime_restore, etc.) use
|
| 14 |
+
the real ``time.sleep`` for threading coordination or assert that it
|
| 15 |
+
was called with specific values. Tests that want to additionally
|
| 16 |
+
fast-path direct ``time.sleep(N)`` calls in production code should
|
| 17 |
+
monkeypatch ``run_agent.time.sleep`` locally (see
|
| 18 |
+
``test_anthropic_error_handling.py`` for the pattern).
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import pytest
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.fixture(autouse=True)
|
| 27 |
+
def _fast_retry_backoff(request, monkeypatch):
|
| 28 |
+
"""Short-circuit retry backoff for all tests in this directory.
|
| 29 |
+
|
| 30 |
+
Tests that assert on the real backoff value opt out with
|
| 31 |
+
``@pytest.mark.real_retry_backoff``.
|
| 32 |
+
"""
|
| 33 |
+
if request.node.get_closest_marker("real_retry_backoff"):
|
| 34 |
+
return
|
| 35 |
+
# The agent.turn_* retry paths import ``jittered_backoff`` lazily from
|
| 36 |
+
# ``agent.retry_utils``; patch it there so rate-limit / invalid-response /
|
| 37 |
+
# server-error retries don't burn real wall-clock seconds.
|
| 38 |
+
from agent import retry_utils as _retry_utils
|
| 39 |
+
monkeypatch.setattr(_retry_utils, "jittered_backoff", lambda *a, **k: 0.0)
|
tests/agent/test_account_usage.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from types import SimpleNamespace
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from agent import account_usage
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class _FakeResponse:
|
| 9 |
+
def __init__(self, payload):
|
| 10 |
+
self._payload = payload
|
| 11 |
+
|
| 12 |
+
def raise_for_status(self):
|
| 13 |
+
return None
|
| 14 |
+
|
| 15 |
+
def json(self):
|
| 16 |
+
return self._payload
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class _FakeClient:
|
| 20 |
+
def __init__(self, calls, payload):
|
| 21 |
+
self.calls = calls
|
| 22 |
+
self.payload = payload
|
| 23 |
+
|
| 24 |
+
def __enter__(self):
|
| 25 |
+
return self
|
| 26 |
+
|
| 27 |
+
def __exit__(self, *exc):
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
def get(self, url, headers):
|
| 31 |
+
self.calls.append({"url": url, "headers": headers})
|
| 32 |
+
return _FakeResponse(self.payload)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.fixture
|
| 36 |
+
def codex_usage_payload():
|
| 37 |
+
return {
|
| 38 |
+
"plan_type": "plus",
|
| 39 |
+
"rate_limit": {
|
| 40 |
+
"primary_window": {
|
| 41 |
+
"used_percent": 21,
|
| 42 |
+
"reset_at": 1779846359,
|
| 43 |
+
},
|
| 44 |
+
"secondary_window": {
|
| 45 |
+
"used_percent": 4,
|
| 46 |
+
"reset_at": 1780230796,
|
| 47 |
+
},
|
| 48 |
+
},
|
| 49 |
+
"credits": {"has_credits": False},
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_codex_usage_prefers_explicit_live_agent_credentials(monkeypatch, codex_usage_payload):
|
| 54 |
+
calls = []
|
| 55 |
+
monkeypatch.setattr(
|
| 56 |
+
account_usage.httpx,
|
| 57 |
+
"Client",
|
| 58 |
+
lambda timeout: _FakeClient(calls, codex_usage_payload),
|
| 59 |
+
)
|
| 60 |
+
monkeypatch.setattr(
|
| 61 |
+
account_usage,
|
| 62 |
+
"resolve_codex_runtime_credentials",
|
| 63 |
+
lambda **kwargs: (_ for _ in ()).throw(AssertionError("legacy auth should not be used")),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
snapshot = account_usage.fetch_account_usage(
|
| 67 |
+
"openai-codex",
|
| 68 |
+
base_url="https://chatgpt.com/backend-api/codex",
|
| 69 |
+
api_key="live-agent-token",
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
assert snapshot is not None
|
| 73 |
+
assert snapshot.provider == "openai-codex"
|
| 74 |
+
assert snapshot.plan == "Plus"
|
| 75 |
+
assert [w.label for w in snapshot.windows] == ["Session", "Weekly"]
|
| 76 |
+
assert snapshot.windows[0].used_percent == 21
|
| 77 |
+
assert calls[0]["url"] == "https://chatgpt.com/backend-api/wham/usage"
|
| 78 |
+
assert calls[0]["headers"]["Authorization"] == "Bearer live-agent-token"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_codex_usage_falls_back_to_native_credential_pool(monkeypatch, codex_usage_payload):
|
| 82 |
+
calls = []
|
| 83 |
+
monkeypatch.setattr(
|
| 84 |
+
account_usage.httpx,
|
| 85 |
+
"Client",
|
| 86 |
+
lambda timeout: _FakeClient(calls, codex_usage_payload),
|
| 87 |
+
)
|
| 88 |
+
# Pool fallback fires only on AuthError (the documented "no creds" mode of
|
| 89 |
+
# the resolver), NOT on arbitrary exceptions — see the transient-error guard
|
| 90 |
+
# test below.
|
| 91 |
+
monkeypatch.setattr(
|
| 92 |
+
account_usage,
|
| 93 |
+
"resolve_codex_runtime_credentials",
|
| 94 |
+
lambda **kwargs: (_ for _ in ()).throw(
|
| 95 |
+
account_usage.AuthError("no singleton auth", provider="openai-codex", code="codex_auth_missing")
|
| 96 |
+
),
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
pool_entry = SimpleNamespace(
|
| 100 |
+
runtime_api_key="pooled-token",
|
| 101 |
+
runtime_base_url="https://chatgpt.com/backend-api/codex",
|
| 102 |
+
)
|
| 103 |
+
pool = SimpleNamespace(select=lambda: pool_entry)
|
| 104 |
+
|
| 105 |
+
import agent.credential_pool as credential_pool
|
| 106 |
+
|
| 107 |
+
monkeypatch.setattr(credential_pool, "load_pool", lambda provider: pool)
|
| 108 |
+
|
| 109 |
+
snapshot = account_usage.fetch_account_usage("openai-codex")
|
| 110 |
+
|
| 111 |
+
assert snapshot is not None
|
| 112 |
+
assert snapshot.windows[0].label == "Session"
|
| 113 |
+
assert snapshot.windows[1].label == "Weekly"
|
| 114 |
+
assert calls[0]["url"] == "https://chatgpt.com/backend-api/wham/usage"
|
| 115 |
+
assert calls[0]["headers"]["Authorization"] == "Bearer pooled-token"
|
| 116 |
+
# Pool creds have no account_id concept — the ChatGPT-Account-Id header must
|
| 117 |
+
# be omitted rather than sent stale/wrong.
|
| 118 |
+
assert "ChatGPT-Account-Id" not in calls[0]["headers"]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_codex_usage_account_id_read_failure_keeps_singleton_token(monkeypatch, codex_usage_payload):
|
| 124 |
+
"""When the resolver succeeds but the separate account_id read raises, the
|
| 125 |
+
working singleton token must still be used (best-effort account_id), NOT
|
| 126 |
+
abandoned in favor of a header-less pool credential."""
|
| 127 |
+
calls = []
|
| 128 |
+
monkeypatch.setattr(
|
| 129 |
+
account_usage.httpx,
|
| 130 |
+
"Client",
|
| 131 |
+
lambda timeout: _FakeClient(calls, codex_usage_payload),
|
| 132 |
+
)
|
| 133 |
+
monkeypatch.setattr(
|
| 134 |
+
account_usage,
|
| 135 |
+
"resolve_codex_runtime_credentials",
|
| 136 |
+
lambda **kwargs: {
|
| 137 |
+
"api_key": "singleton-token",
|
| 138 |
+
"base_url": "https://chatgpt.com/backend-api/codex",
|
| 139 |
+
},
|
| 140 |
+
)
|
| 141 |
+
monkeypatch.setattr(
|
| 142 |
+
account_usage,
|
| 143 |
+
"_read_codex_tokens",
|
| 144 |
+
lambda *a, **k: (_ for _ in ()).throw(
|
| 145 |
+
account_usage.AuthError("partial store", provider="openai-codex", code="codex_auth_invalid_shape")
|
| 146 |
+
),
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
import agent.credential_pool as credential_pool
|
| 150 |
+
|
| 151 |
+
monkeypatch.setattr(
|
| 152 |
+
credential_pool,
|
| 153 |
+
"load_pool",
|
| 154 |
+
lambda provider: (_ for _ in ()).throw(AssertionError("pool must not be consulted")),
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
snapshot = account_usage.fetch_account_usage("openai-codex")
|
| 158 |
+
|
| 159 |
+
assert snapshot is not None
|
| 160 |
+
assert calls[0]["headers"]["Authorization"] == "Bearer singleton-token"
|
| 161 |
+
# account_id read failed → header omitted, but the singleton token is kept.
|
| 162 |
+
assert "ChatGPT-Account-Id" not in calls[0]["headers"]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ── Banked rate-limit reset credits (`/usage reset`) ─────────────────────────
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class _FakeResetClient:
|
| 171 |
+
"""GET returns the usage payload; POST returns the consume payload."""
|
| 172 |
+
|
| 173 |
+
def __init__(self, calls, usage_payload, consume_payload=None):
|
| 174 |
+
self.calls = calls
|
| 175 |
+
self.usage_payload = usage_payload
|
| 176 |
+
self.consume_payload = consume_payload or {}
|
| 177 |
+
|
| 178 |
+
def __enter__(self):
|
| 179 |
+
return self
|
| 180 |
+
|
| 181 |
+
def __exit__(self, *exc):
|
| 182 |
+
return False
|
| 183 |
+
|
| 184 |
+
def get(self, url, headers):
|
| 185 |
+
self.calls.append({"method": "GET", "url": url, "headers": headers})
|
| 186 |
+
return _FakeResponse(self.usage_payload)
|
| 187 |
+
|
| 188 |
+
def post(self, url, headers=None, json=None):
|
| 189 |
+
self.calls.append({"method": "POST", "url": url, "headers": headers, "json": json})
|
| 190 |
+
return _FakeResponse(self.consume_payload)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _usage_payload_with_resets(primary_used, secondary_used, banked):
|
| 194 |
+
return {
|
| 195 |
+
"plan_type": "plus",
|
| 196 |
+
"rate_limit": {
|
| 197 |
+
"primary_window": {"used_percent": primary_used, "reset_at": 1779846359},
|
| 198 |
+
"secondary_window": {"used_percent": secondary_used, "reset_at": 1780230796},
|
| 199 |
+
},
|
| 200 |
+
"rate_limit_reset_credits": {"available_count": banked},
|
| 201 |
+
"credits": {"has_credits": False},
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_redeem_missing_credentials_reports_unavailable(monkeypatch):
|
| 220 |
+
monkeypatch.setattr(
|
| 221 |
+
account_usage,
|
| 222 |
+
"_resolve_codex_usage_credentials",
|
| 223 |
+
lambda base_url, api_key: (_ for _ in ()).throw(RuntimeError("no creds")),
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
result = account_usage.redeem_codex_reset_credit()
|
| 227 |
+
|
| 228 |
+
assert result.status == "unavailable"
|
| 229 |
+
assert "hermes auth" in result.message
|
tests/agent/test_anthropic_borrowed_row_authority.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The borrowed ``claude_code`` row is a reference, never a token authority.
|
| 2 |
+
|
| 3 |
+
``claude_code`` is absent from ``_PERSISTABLE_PROVIDER_SOURCES``, so
|
| 4 |
+
``sanitize_borrowed_credential_payload`` strips ``access_token`` and
|
| 5 |
+
``refresh_token`` before the pool row reaches ``auth.json``: what survives on
|
| 6 |
+
disk is provenance, status and a ``secret_fingerprint``. ``load_pool()``
|
| 7 |
+
re-hydrates the live pair from ``~/.claude/.credentials.json`` on every load,
|
| 8 |
+
which is what makes the singleton -- not the pool store -- authoritative for
|
| 9 |
+
this source.
|
| 10 |
+
|
| 11 |
+
Two failure modes follow from forgetting that, and both are covered here:
|
| 12 |
+
|
| 13 |
+
1. ``_sync_anthropic_entry_from_pool_store()`` re-reads the persisted row
|
| 14 |
+
during refresh. For a borrowed source that row has *no* tokens, so it
|
| 15 |
+
"differs" from the live entry and was adopted as though another process had
|
| 16 |
+
rotated the pair -- blanking a usable credential and returning before
|
| 17 |
+
``_claude_code_credentials_lock()`` and the authoritative re-read were ever
|
| 18 |
+
entered.
|
| 19 |
+
2. ``_available_entries()`` only refused to lease empty *API-key* rows, so the
|
| 20 |
+
blanked OAuth entry stayed selectable and would have been sent as an empty
|
| 21 |
+
bearer.
|
| 22 |
+
|
| 23 |
+
The existing race/write-through suites build ``CredentialPool`` objects
|
| 24 |
+
directly or back the store with unsanitized in-memory rows, so neither crosses
|
| 25 |
+
the real persistence boundary. Every test below starts from ``load_pool()``
|
| 26 |
+
reading an actually persisted, actually sanitized row.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import json
|
| 32 |
+
import time
|
| 33 |
+
from dataclasses import replace as dc_replace
|
| 34 |
+
|
| 35 |
+
import pytest
|
| 36 |
+
|
| 37 |
+
from agent import anthropic_credentials as AA
|
| 38 |
+
from agent.credential_persistence import sanitize_borrowed_credential_payload
|
| 39 |
+
from agent.credential_pool import (
|
| 40 |
+
AUTH_TYPE_OAUTH,
|
| 41 |
+
CredentialPool,
|
| 42 |
+
PooledCredential,
|
| 43 |
+
load_pool,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
_EXPIRED_MS = 1_000
|
| 47 |
+
|
| 48 |
+
_STALE_ACCESS = "sk-ant-oat01-borrowed-stale"
|
| 49 |
+
_STALE_REFRESH = "sk-ant-ort01-borrowed-stale"
|
| 50 |
+
_ROTATED_ACCESS = "sk-ant-oat01-borrowed-rotated"
|
| 51 |
+
_ROTATED_REFRESH = "sk-ant-ort01-borrowed-rotated"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@pytest.fixture
|
| 55 |
+
def hermes_home(tmp_path, monkeypatch):
|
| 56 |
+
"""Real on-disk HERMES_HOME so ``load_pool()`` re-reads what it persisted."""
|
| 57 |
+
home = tmp_path / "hermes"
|
| 58 |
+
home.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
monkeypatch.setenv("HERMES_HOME", str(home))
|
| 60 |
+
for var in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
| 61 |
+
monkeypatch.delenv(var, raising=False)
|
| 62 |
+
(home / "auth.json").write_text(
|
| 63 |
+
json.dumps({"version": 1, "providers": {}}), encoding="utf-8"
|
| 64 |
+
)
|
| 65 |
+
monkeypatch.setattr(
|
| 66 |
+
"hermes_cli.auth.is_provider_explicitly_configured", lambda pid: True
|
| 67 |
+
)
|
| 68 |
+
return home
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.fixture
|
| 72 |
+
def claude_credentials(tmp_path, monkeypatch):
|
| 73 |
+
"""Point the ``claude_code`` singleton at a tmp file holding a stale pair."""
|
| 74 |
+
cred_path = tmp_path / "claude" / ".credentials.json"
|
| 75 |
+
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
cred_path.write_text(
|
| 77 |
+
json.dumps(
|
| 78 |
+
{
|
| 79 |
+
"claudeAiOauth": {
|
| 80 |
+
"accessToken": _STALE_ACCESS,
|
| 81 |
+
"refreshToken": _STALE_REFRESH,
|
| 82 |
+
"expiresAt": _EXPIRED_MS,
|
| 83 |
+
"scopes": ["user:inference", "user:profile"],
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
),
|
| 87 |
+
encoding="utf-8",
|
| 88 |
+
)
|
| 89 |
+
monkeypatch.setattr(AA, "claude_code_credentials_path", lambda: cred_path)
|
| 90 |
+
monkeypatch.setattr(AA, "_read_claude_code_credentials_from_keychain", lambda: None)
|
| 91 |
+
return cred_path
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _persisted_rows(home):
|
| 95 |
+
store = json.loads((home / "auth.json").read_text(encoding="utf-8"))
|
| 96 |
+
return store.get("credential_pool", {}).get("anthropic", [])
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _claude_pair(cred_path):
|
| 100 |
+
data = json.loads(cred_path.read_text(encoding="utf-8"))["claudeAiOauth"]
|
| 101 |
+
return data["accessToken"], data["refreshToken"]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _rotating_refresh(refresh_token, **_kw):
|
| 105 |
+
return {
|
| 106 |
+
"access_token": _ROTATED_ACCESS,
|
| 107 |
+
"refresh_token": _ROTATED_REFRESH,
|
| 108 |
+
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_persisted_claude_code_row_carries_no_token_material(
|
| 113 |
+
hermes_home, claude_credentials
|
| 114 |
+
):
|
| 115 |
+
"""Baseline: the row the refresh path re-reads really is sanitized.
|
| 116 |
+
|
| 117 |
+
Every other test in this file only means something if the disk row is
|
| 118 |
+
token-less, so assert the boundary rather than assuming it.
|
| 119 |
+
"""
|
| 120 |
+
pool = load_pool("anthropic")
|
| 121 |
+
|
| 122 |
+
live = [e for e in pool._entries if e.source == "claude_code"]
|
| 123 |
+
assert len(live) == 1
|
| 124 |
+
assert live[0].access_token == _STALE_ACCESS, (
|
| 125 |
+
"load_pool must hydrate the live pair from the singleton"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
rows = [r for r in _persisted_rows(hermes_home) if r.get("source") == "claude_code"]
|
| 129 |
+
assert len(rows) == 1
|
| 130 |
+
assert not rows[0].get("access_token")
|
| 131 |
+
assert not rows[0].get("refresh_token")
|
| 132 |
+
assert str(rows[0].get("secret_fingerprint", "")).startswith("sha256:")
|
| 133 |
+
assert sanitize_borrowed_credential_payload(rows[0], "anthropic") == rows[0]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_pool_store_sync_never_adopts_a_borrowed_row(hermes_home, claude_credentials):
|
| 137 |
+
"""The sanitized row must not be mistaken for a rotation by another process."""
|
| 138 |
+
pool = load_pool("anthropic")
|
| 139 |
+
entry = next(e for e in pool._entries if e.source == "claude_code")
|
| 140 |
+
|
| 141 |
+
synced = pool._sync_anthropic_entry_from_pool_store(entry)
|
| 142 |
+
|
| 143 |
+
assert synced is entry, "a borrowed row is a reference, not token authority"
|
| 144 |
+
assert synced.access_token == _STALE_ACCESS
|
| 145 |
+
assert synced.refresh_token == _STALE_REFRESH
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_refresh_from_persisted_sanitized_row_keeps_the_full_pair(
|
| 149 |
+
hermes_home, claude_credentials, monkeypatch
|
| 150 |
+
):
|
| 151 |
+
"""The production ``load -> sanitize -> refresh`` path refreshes, not blanks.
|
| 152 |
+
|
| 153 |
+
Exactly one POST and one authoritative write, the returned entry carries
|
| 154 |
+
the complete rotated pair, and the shared credentials file is the copy that
|
| 155 |
+
was updated.
|
| 156 |
+
"""
|
| 157 |
+
posts = []
|
| 158 |
+
writes = []
|
| 159 |
+
|
| 160 |
+
def _counting_refresh(refresh_token, **kwargs):
|
| 161 |
+
posts.append(refresh_token)
|
| 162 |
+
return _rotating_refresh(refresh_token, **kwargs)
|
| 163 |
+
|
| 164 |
+
real_write = AA._write_claude_code_credentials
|
| 165 |
+
|
| 166 |
+
def _counting_write(access_token, refresh_token, expires_at_ms):
|
| 167 |
+
writes.append(refresh_token)
|
| 168 |
+
return real_write(access_token, refresh_token, expires_at_ms)
|
| 169 |
+
|
| 170 |
+
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _counting_refresh)
|
| 171 |
+
monkeypatch.setattr(AA, "_write_claude_code_credentials", _counting_write)
|
| 172 |
+
|
| 173 |
+
pool = load_pool("anthropic")
|
| 174 |
+
entry = next(e for e in pool._entries if e.source == "claude_code")
|
| 175 |
+
|
| 176 |
+
refreshed = pool._refresh_entry(entry, force=True)
|
| 177 |
+
|
| 178 |
+
assert refreshed is not None, "the refresh must not be abandoned"
|
| 179 |
+
assert refreshed.access_token == _ROTATED_ACCESS
|
| 180 |
+
assert refreshed.refresh_token == _ROTATED_REFRESH
|
| 181 |
+
assert posts == [_STALE_REFRESH], f"expected exactly one POST, got {posts}"
|
| 182 |
+
assert writes == [_ROTATED_REFRESH], f"expected exactly one commit, got {writes}"
|
| 183 |
+
assert _claude_pair(claude_credentials) == (_ROTATED_ACCESS, _ROTATED_REFRESH)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_refresh_reaches_the_shared_credentials_lock(
|
| 187 |
+
hermes_home, claude_credentials, monkeypatch
|
| 188 |
+
):
|
| 189 |
+
"""``claude_code`` must always take the path-keyed lock before deciding.
|
| 190 |
+
|
| 191 |
+
That lock is what serializes profiles sharing one
|
| 192 |
+
``~/.claude/.credentials.json``; an adopt-and-return shortcut firing first
|
| 193 |
+
would leave the cross-profile race exactly where it was.
|
| 194 |
+
"""
|
| 195 |
+
taken = []
|
| 196 |
+
real_lock = CredentialPool._claude_code_credentials_lock
|
| 197 |
+
|
| 198 |
+
def _tracking_lock(self):
|
| 199 |
+
taken.append(True)
|
| 200 |
+
return real_lock(self)
|
| 201 |
+
|
| 202 |
+
monkeypatch.setattr(CredentialPool, "_claude_code_credentials_lock", _tracking_lock)
|
| 203 |
+
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
| 204 |
+
|
| 205 |
+
pool = load_pool("anthropic")
|
| 206 |
+
entry = next(e for e in pool._entries if e.source == "claude_code")
|
| 207 |
+
pool._refresh_entry(entry, force=True)
|
| 208 |
+
|
| 209 |
+
assert taken, "the authoritative re-read must happen under the shared-file lock"
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_empty_oauth_entry_is_never_leased(hermes_home, claude_credentials):
|
| 213 |
+
"""A token-less OAuth row must not be selectable as an empty bearer.
|
| 214 |
+
|
| 215 |
+
The pre-existing guard covered ``AUTH_TYPE_API_KEY`` only, so an OAuth row
|
| 216 |
+
that failed to hydrate went straight into the available list.
|
| 217 |
+
"""
|
| 218 |
+
pool = load_pool("anthropic")
|
| 219 |
+
entry = next(e for e in pool._entries if e.source == "claude_code")
|
| 220 |
+
blanked = dc_replace(entry, access_token="", refresh_token="")
|
| 221 |
+
pool._replace_entry(entry, blanked)
|
| 222 |
+
|
| 223 |
+
available, _pending = pool._available_entries(clear_expired=False, refresh=False)
|
| 224 |
+
|
| 225 |
+
assert all(e.access_token for e in available), (
|
| 226 |
+
"an OAuth entry with no access token must never be leased"
|
| 227 |
+
)
|
| 228 |
+
assert blanked.id not in {e.id for e in available}
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def test_selection_after_refresh_leases_only_hydrated_entries(
|
| 232 |
+
hermes_home, claude_credentials, monkeypatch
|
| 233 |
+
):
|
| 234 |
+
"""End-to-end: refresh through selection leaves a usable, non-empty lease."""
|
| 235 |
+
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
| 236 |
+
|
| 237 |
+
pool = load_pool("anthropic")
|
| 238 |
+
available, _pending = pool._available_entries(clear_expired=True, refresh=True)
|
| 239 |
+
|
| 240 |
+
assert available, "the credential must survive the refresh, not be dropped"
|
| 241 |
+
assert all(e.access_token for e in available)
|
| 242 |
+
assert any(e.access_token == _ROTATED_ACCESS for e in available)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def test_hermes_pkce_row_still_syncs_from_the_pool_store(monkeypatch):
|
| 246 |
+
"""The borrowed-source refusal must not disable pool-owned adoption.
|
| 247 |
+
|
| 248 |
+
``hermes_pkce`` *is* pool-owned, so its persisted row keeps its tokens and
|
| 249 |
+
stays a legitimate rotation witness for another pool instance.
|
| 250 |
+
"""
|
| 251 |
+
rotated = {
|
| 252 |
+
"id": "anthropic-pkce",
|
| 253 |
+
"label": "anthropic oauth",
|
| 254 |
+
"auth_type": AUTH_TYPE_OAUTH,
|
| 255 |
+
"priority": 0,
|
| 256 |
+
"source": "hermes_pkce",
|
| 257 |
+
"access_token": _ROTATED_ACCESS,
|
| 258 |
+
"refresh_token": _ROTATED_REFRESH,
|
| 259 |
+
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
|
| 260 |
+
}
|
| 261 |
+
monkeypatch.setattr(
|
| 262 |
+
"agent.credential_pool.read_credential_pool", lambda provider=None: [rotated]
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
entry = PooledCredential(
|
| 266 |
+
provider="anthropic",
|
| 267 |
+
id="anthropic-pkce",
|
| 268 |
+
label="anthropic oauth",
|
| 269 |
+
auth_type=AUTH_TYPE_OAUTH,
|
| 270 |
+
priority=0,
|
| 271 |
+
source="hermes_pkce",
|
| 272 |
+
access_token=_STALE_ACCESS,
|
| 273 |
+
refresh_token=_STALE_REFRESH,
|
| 274 |
+
expires_at_ms=_EXPIRED_MS,
|
| 275 |
+
)
|
| 276 |
+
pool = CredentialPool("anthropic", [entry])
|
| 277 |
+
|
| 278 |
+
synced = pool._sync_anthropic_entry_from_pool_store(entry)
|
| 279 |
+
|
| 280 |
+
assert synced.access_token == _ROTATED_ACCESS
|
| 281 |
+
assert synced.refresh_token == _ROTATED_REFRESH
|
tests/agent/test_anthropic_client_auth.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Auth-boundary shard for the Anthropic adapter.
|
| 2 |
+
|
| 3 |
+
An api-key-style client must not inherit ANTHROPIC_AUTH_TOKEN from the environment: the SDK
|
| 4 |
+
fills an unset ``auth_token`` from that variable and then sends ``Authorization: Bearer ***``
|
| 5 |
+
alongside ``x-api-key`` on every request, shipping a foreign shell credential to third-party
|
| 6 |
+
Anthropic-compatible endpoints (#105774). Kept out of ``test_anthropic_adapter.py`` for the
|
| 7 |
+
per-file line ceiling.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from agent.anthropic_adapter import build_anthropic_client
|
| 13 |
+
|
| 14 |
+
SENTINEL = "sentinel-env-token-DO-NOT-SEND"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def wire_headers(client) -> dict:
|
| 18 |
+
"""Headers the SDK would put on a /v1/messages POST (the Omit() default is resolved here)."""
|
| 19 |
+
from anthropic._models import FinalRequestOptions
|
| 20 |
+
|
| 21 |
+
return dict(client._build_headers(FinalRequestOptions(method="post", url="/v1/messages", json_data={})))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_api_key_client_and_its_copies_never_carry_the_env_bearer(monkeypatch):
|
| 26 |
+
"""The guard is a copy-safe Omit() default header: ``with_options()`` re-runs the constructor
|
| 27 |
+
and re-reads ANTHROPIC_AUTH_TOKEN, so an attribute clear alone would re-leak on the copy."""
|
| 28 |
+
anthropic_sdk = pytest.importorskip("anthropic")
|
| 29 |
+
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", SENTINEL)
|
| 30 |
+
from agent.anthropic_adapter import _new_sdk_client
|
| 31 |
+
|
| 32 |
+
client = _new_sdk_client(anthropic_sdk, {"api_key": "provider-key", "base_url": "http://127.0.0.1:1"}, {})
|
| 33 |
+
for wire_client in (client, client.with_options(timeout=30)):
|
| 34 |
+
headers = wire_headers(wire_client)
|
| 35 |
+
assert headers.get("x-api-key") == "provider-key"
|
| 36 |
+
assert "authorization" not in headers
|
| 37 |
+
# The bearer mirror (no env x-api-key beside a portal JWT) is owned by
|
| 38 |
+
# tests/agent/test_nous_portal_anthropic_wire.py::TestClientShape.
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_third_party_request_on_the_wire_carries_no_foreign_bearer(monkeypatch):
|
| 42 |
+
"""End-to-end through ``build_anthropic_client`` against a local header-capturing server."""
|
| 43 |
+
pytest.importorskip("anthropic")
|
| 44 |
+
import json
|
| 45 |
+
import threading
|
| 46 |
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
| 47 |
+
|
| 48 |
+
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", SENTINEL)
|
| 49 |
+
captured = {}
|
| 50 |
+
|
| 51 |
+
class _Handler(BaseHTTPRequestHandler):
|
| 52 |
+
def do_POST(self):
|
| 53 |
+
captured["headers"] = {k.lower(): v for k, v in self.headers.items()}
|
| 54 |
+
self.rfile.read(int(self.headers.get("content-length", 0)))
|
| 55 |
+
body = json.dumps({
|
| 56 |
+
"id": "msg_test", "type": "message", "role": "assistant",
|
| 57 |
+
"content": [{"type": "text", "text": "ok"}], "model": "test",
|
| 58 |
+
"stop_reason": "end_turn", "usage": {"input_tokens": 1, "output_tokens": 1},
|
| 59 |
+
}).encode()
|
| 60 |
+
self.send_response(200)
|
| 61 |
+
self.send_header("content-type", "application/json")
|
| 62 |
+
self.send_header("content-length", str(len(body)))
|
| 63 |
+
self.end_headers()
|
| 64 |
+
self.wfile.write(body)
|
| 65 |
+
|
| 66 |
+
def log_message(self, *args):
|
| 67 |
+
pass
|
| 68 |
+
|
| 69 |
+
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
| 70 |
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
| 71 |
+
try:
|
| 72 |
+
client = build_anthropic_client("third-party-provider-key", base_url=f"http://127.0.0.1:{server.server_port}")
|
| 73 |
+
client.with_options(timeout=30).messages.create(
|
| 74 |
+
model="test-model", max_tokens=8, messages=[{"role": "user", "content": "hi"}])
|
| 75 |
+
finally:
|
| 76 |
+
server.shutdown()
|
| 77 |
+
|
| 78 |
+
assert captured["headers"].get("x-api-key") == "third-party-provider-key"
|
| 79 |
+
assert "authorization" not in captured["headers"]
|
tests/agent/test_anthropic_oauth_stress.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load / stress test for the Anthropic OAuth cross-process refresh race fix.
|
| 2 |
+
|
| 3 |
+
Companion to ``tests/agent/test_credential_pool_anthropic_refresh_race.py``,
|
| 4 |
+
which proves the bug in isolation with two racers. This test scales the same scenario up to look for bottlenecks and degradation
|
| 5 |
+
under real concurrency. The thread stress case keeps the suite fast while a
|
| 6 |
+
separate spawn-based case uses independent interpreters, distinct profile
|
| 7 |
+
homes, and one shared Claude Code credentials file. Both exercise the REAL
|
| 8 |
+
cross-process file lock (``_auth_store_lock``) and REAL credential-pool
|
| 9 |
+
persistence under throwaway directories — only the network call to Anthropic
|
| 10 |
+
is faked. The process case also counts refresh POSTs and requires exactly one
|
| 11 |
+
use of the stale single-use token, so a broken lock cannot remain green merely
|
| 12 |
+
because two in-process mocks happened to finish quickly.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import multiprocessing as mp
|
| 19 |
+
import os
|
| 20 |
+
import queue
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
from dataclasses import replace as dc_replace
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import pytest
|
| 27 |
+
|
| 28 |
+
from agent.credential_pool import (
|
| 29 |
+
AUTH_TYPE_OAUTH,
|
| 30 |
+
STATUS_EXHAUSTED,
|
| 31 |
+
CredentialPool,
|
| 32 |
+
PooledCredential,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
CONCURRENCY = 20
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _process_claude_code_refresh_worker(
|
| 39 |
+
profile_home: str,
|
| 40 |
+
shared_credentials_path: str,
|
| 41 |
+
server_state_path: str,
|
| 42 |
+
start_event,
|
| 43 |
+
result_queue,
|
| 44 |
+
) -> None:
|
| 45 |
+
"""Refresh one shared Claude Code credential from an independent process."""
|
| 46 |
+
os.environ["HERMES_HOME"] = profile_home
|
| 47 |
+
|
| 48 |
+
from agent import anthropic_credentials as anthropic_mod
|
| 49 |
+
from agent import credential_pool as credential_pool_mod
|
| 50 |
+
from hermes_cli import auth as auth_mod
|
| 51 |
+
|
| 52 |
+
shared_path = Path(shared_credentials_path)
|
| 53 |
+
server_path = Path(server_state_path)
|
| 54 |
+
|
| 55 |
+
def read_shared_credentials():
|
| 56 |
+
data = json.loads(shared_path.read_text(encoding="utf-8"))
|
| 57 |
+
oauth = data["claudeAiOauth"]
|
| 58 |
+
return {
|
| 59 |
+
"accessToken": oauth["accessToken"],
|
| 60 |
+
"refreshToken": oauth.get("refreshToken", ""),
|
| 61 |
+
"expiresAt": oauth.get("expiresAt", 0),
|
| 62 |
+
"source": "claude_code_credentials_file",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
def write_shared_credentials(access_token, refresh_token, expires_at_ms, **_kwargs):
|
| 66 |
+
data = json.loads(shared_path.read_text(encoding="utf-8"))
|
| 67 |
+
data["claudeAiOauth"] = {
|
| 68 |
+
"accessToken": access_token,
|
| 69 |
+
"refreshToken": refresh_token,
|
| 70 |
+
"expiresAt": expires_at_ms,
|
| 71 |
+
}
|
| 72 |
+
shared_path.write_text(json.dumps(data), encoding="utf-8")
|
| 73 |
+
|
| 74 |
+
def fake_refresh(refresh_token, *, use_json=False):
|
| 75 |
+
# The state file models a single-use token endpoint. The lock here
|
| 76 |
+
# protects only the fake server's accounting; the production lock is
|
| 77 |
+
# what must ensure that the second Hermes process never calls this
|
| 78 |
+
# function after the first one has rotated the shared credential.
|
| 79 |
+
with auth_mod._auth_store_lock(timeout_seconds=10, target_path=server_path):
|
| 80 |
+
state = json.loads(server_path.read_text(encoding="utf-8"))
|
| 81 |
+
state["calls"].append(refresh_token)
|
| 82 |
+
if refresh_token in state["spent"]:
|
| 83 |
+
server_path.write_text(json.dumps(state), encoding="utf-8")
|
| 84 |
+
raise ValueError("invalid_grant: refresh token already used")
|
| 85 |
+
state["spent"].append(refresh_token)
|
| 86 |
+
state["rotation"] += 1
|
| 87 |
+
rotation = state["rotation"]
|
| 88 |
+
server_path.write_text(json.dumps(state), encoding="utf-8")
|
| 89 |
+
# Keep the simulated network operation inside the production shared
|
| 90 |
+
# lock long enough for the second profile to prove it waits, then
|
| 91 |
+
# re-reads the newly-written shared credentials file.
|
| 92 |
+
time.sleep(0.1)
|
| 93 |
+
return {
|
| 94 |
+
"access_token": f"process-access-{rotation}",
|
| 95 |
+
"refresh_token": f"process-refresh-{rotation}",
|
| 96 |
+
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
# Keep this worker hermetic: each profile has its own auth store, while
|
| 100 |
+
# both workers deliberately point at the same Claude credential source.
|
| 101 |
+
auth_mod._global_auth_file_path = lambda: None
|
| 102 |
+
anthropic_mod.claude_code_credentials_path = lambda: shared_path
|
| 103 |
+
anthropic_mod.read_claude_code_credentials = read_shared_credentials
|
| 104 |
+
anthropic_mod._write_claude_code_credentials = write_shared_credentials
|
| 105 |
+
anthropic_mod.refresh_anthropic_oauth_pure = fake_refresh
|
| 106 |
+
|
| 107 |
+
result_queue.put({"kind": "ready", "pid": os.getpid()})
|
| 108 |
+
if not start_event.wait(timeout=10):
|
| 109 |
+
result_queue.put({"kind": "result", "ok": False, "error": "start barrier timeout"})
|
| 110 |
+
return
|
| 111 |
+
|
| 112 |
+
entry = _entry(id="pool-entry", refresh_token="stale-rt", source="claude_code")
|
| 113 |
+
pool = credential_pool_mod.CredentialPool("anthropic", [entry])
|
| 114 |
+
try:
|
| 115 |
+
refreshed = pool._refresh_entry(pool.entries()[0], force=True)
|
| 116 |
+
result_queue.put({
|
| 117 |
+
"kind": "result",
|
| 118 |
+
"ok": refreshed is not None,
|
| 119 |
+
"refresh_token": refreshed.refresh_token if refreshed else None,
|
| 120 |
+
"pool_refresh_token": pool.entries()[0].refresh_token,
|
| 121 |
+
})
|
| 122 |
+
except BaseException as exc: # pragma: no cover - failure diagnostics
|
| 123 |
+
result_queue.put({"kind": "result", "ok": False, "error": repr(exc)})
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _entry(*, id: str, refresh_token: str, source: str) -> PooledCredential:
|
| 127 |
+
return PooledCredential(
|
| 128 |
+
provider="anthropic",
|
| 129 |
+
id=id,
|
| 130 |
+
label="anthropic oauth",
|
| 131 |
+
auth_type=AUTH_TYPE_OAUTH,
|
| 132 |
+
priority=0,
|
| 133 |
+
source=source,
|
| 134 |
+
access_token="stale-at",
|
| 135 |
+
refresh_token=refresh_token,
|
| 136 |
+
expires_at_ms=0,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class _SingleUseTokenServer:
|
| 141 |
+
"""Same single-use-refresh-token contract as the race test, tuned for
|
| 142 |
+
a wider fan-out (more callers, less per-call delay so the suite stays
|
| 143 |
+
fast while still exercising real contention).
|
| 144 |
+
"""
|
| 145 |
+
|
| 146 |
+
def __init__(self, delay_seconds: float = 0.02) -> None:
|
| 147 |
+
self._lock = threading.Lock()
|
| 148 |
+
self._spent: set[str] = set()
|
| 149 |
+
self._rotation = 0
|
| 150 |
+
self.calls: list[str] = []
|
| 151 |
+
self.delay_seconds = delay_seconds
|
| 152 |
+
|
| 153 |
+
def refresh(self, refresh_token: str, *, use_json: bool = False):
|
| 154 |
+
with self._lock:
|
| 155 |
+
self.calls.append(refresh_token)
|
| 156 |
+
time.sleep(self.delay_seconds)
|
| 157 |
+
with self._lock:
|
| 158 |
+
if refresh_token in self._spent:
|
| 159 |
+
raise ValueError("invalid_grant: refresh token already used")
|
| 160 |
+
self._spent.add(refresh_token)
|
| 161 |
+
self._rotation += 1
|
| 162 |
+
rotation = self._rotation
|
| 163 |
+
return {
|
| 164 |
+
"access_token": f"sk-ant-oat-rotated-{rotation}",
|
| 165 |
+
"refresh_token": f"sk-ant-ort-rotated-{rotation}",
|
| 166 |
+
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@pytest.fixture
|
| 171 |
+
def hermes_home(tmp_path, monkeypatch):
|
| 172 |
+
"""Real, throwaway HERMES_HOME so _auth_store_lock and
|
| 173 |
+
write_credential_pool/read_credential_pool exercise the genuine
|
| 174 |
+
file-lock + on-disk persistence path, not a mock.
|
| 175 |
+
"""
|
| 176 |
+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
| 177 |
+
return tmp_path
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_high_concurrency_anthropic_refresh_no_lost_updates_no_deadlock(
|
| 181 |
+
hermes_home, monkeypatch
|
| 182 |
+
):
|
| 183 |
+
"""CONCURRENCY 'Hermes processes' race the same stale refresh token
|
| 184 |
+
against the real cross-process lock + real on-disk pool persistence.
|
| 185 |
+
|
| 186 |
+
Bottleneck check: total wall-clock time must stay close to what a
|
| 187 |
+
correctly-serialized (or adopt-without-refreshing) implementation would
|
| 188 |
+
take, not blow up toward CONCURRENCY * network_delay -- and every
|
| 189 |
+
participant must end up with a usable, non-exhausted credential.
|
| 190 |
+
"""
|
| 191 |
+
server = _SingleUseTokenServer(delay_seconds=0.02)
|
| 192 |
+
monkeypatch.setattr(
|
| 193 |
+
"agent.anthropic_credentials.refresh_anthropic_oauth_pure",
|
| 194 |
+
lambda refresh_token, use_json=False: server.refresh(refresh_token, use_json=use_json),
|
| 195 |
+
)
|
| 196 |
+
monkeypatch.setattr(
|
| 197 |
+
"agent.anthropic_credentials.read_claude_code_credentials", lambda: None
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
shared_stale_entry = _entry(
|
| 201 |
+
id="pool-entry", refresh_token="stale-rt", source="manual:hermes_pkce"
|
| 202 |
+
)
|
| 203 |
+
pools = [
|
| 204 |
+
CredentialPool("anthropic", [dc_replace(shared_stale_entry)])
|
| 205 |
+
for _ in range(CONCURRENCY)
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
results: dict[int, object] = {}
|
| 209 |
+
errors: dict[int, BaseException] = {}
|
| 210 |
+
|
| 211 |
+
def _run(idx: int) -> None:
|
| 212 |
+
try:
|
| 213 |
+
entry = pools[idx].entries()[0]
|
| 214 |
+
results[idx] = pools[idx]._refresh_entry(entry, force=True)
|
| 215 |
+
except BaseException as exc: # pragma: no cover - failure diagnostics
|
| 216 |
+
errors[idx] = exc
|
| 217 |
+
|
| 218 |
+
threads = [threading.Thread(target=_run, args=(i,)) for i in range(CONCURRENCY)]
|
| 219 |
+
start = time.monotonic()
|
| 220 |
+
for t in threads:
|
| 221 |
+
t.start()
|
| 222 |
+
# Generous per-thread join budget: a correct implementation serializes
|
| 223 |
+
# through one file lock, so worst case is roughly
|
| 224 |
+
# CONCURRENCY * (delay + lock overhead), well under this ceiling. A
|
| 225 |
+
# deadlock or livelock would blow straight through it.
|
| 226 |
+
deadline = start + max(10.0, CONCURRENCY * server.delay_seconds * 5)
|
| 227 |
+
for t in threads:
|
| 228 |
+
remaining = max(0.1, deadline - time.monotonic())
|
| 229 |
+
t.join(timeout=remaining)
|
| 230 |
+
elapsed = time.monotonic() - start
|
| 231 |
+
|
| 232 |
+
still_alive = [t for t in threads if t.is_alive()]
|
| 233 |
+
assert not still_alive, (
|
| 234 |
+
f"{len(still_alive)}/{CONCURRENCY} threads never finished -- "
|
| 235 |
+
"possible deadlock in the cross-process refresh lock."
|
| 236 |
+
)
|
| 237 |
+
assert not errors, f"unexpected exceptions during concurrent refresh: {errors!r}"
|
| 238 |
+
|
| 239 |
+
assert len(results) == CONCURRENCY
|
| 240 |
+
assert all(r is not None for r in results.values()), (
|
| 241 |
+
"at least one of the concurrent processes could not recover a "
|
| 242 |
+
"usable Anthropic credential after the refresh race"
|
| 243 |
+
)
|
| 244 |
+
for idx, pool in enumerate(pools):
|
| 245 |
+
entry_after = pool.entries()[0]
|
| 246 |
+
assert entry_after.last_status != STATUS_EXHAUSTED, (
|
| 247 |
+
f"process {idx} ended up with an exhausted Anthropic credential "
|
| 248 |
+
"despite valid tokens existing on disk"
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
# Bottleneck signal: this must stay well below "every thread pays the
|
| 252 |
+
# full network delay independently" (CONCURRENCY * delay). If the fix
|
| 253 |
+
# regresses into N sequential POSTs instead of lock+adopt, this is
|
| 254 |
+
# where it would show up first.
|
| 255 |
+
naive_serial_upper_bound = CONCURRENCY * server.delay_seconds * 3
|
| 256 |
+
assert elapsed < naive_serial_upper_bound, (
|
| 257 |
+
f"refresh race took {elapsed:.2f}s for {CONCURRENCY} concurrent "
|
| 258 |
+
f"processes -- expected well under {naive_serial_upper_bound:.2f}s "
|
| 259 |
+
"if the lock + pool-store adoption path is working efficiently"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@pytest.mark.live_system_guard_bypass
|
| 264 |
+
@pytest.mark.windows_only
|
| 265 |
+
def test_distinct_profiles_share_one_claude_refresh_without_duplicate_post(
|
| 266 |
+
hermes_home,
|
| 267 |
+
):
|
| 268 |
+
"""Independent profiles must serialize a shared Claude Code refresh.
|
| 269 |
+
|
| 270 |
+
The profile auth locks intentionally have different paths here; only the
|
| 271 |
+
dedicated lock keyed to the shared Claude credentials file can prevent the
|
| 272 |
+
second process from POSTing the already-spent refresh token.
|
| 273 |
+
"""
|
| 274 |
+
shared_credentials_path = hermes_home / "shared-claude-credentials.json"
|
| 275 |
+
shared_credentials_path.write_text(
|
| 276 |
+
json.dumps({
|
| 277 |
+
"claudeAiOauth": {
|
| 278 |
+
"accessToken": "stale-at",
|
| 279 |
+
"refreshToken": "stale-rt",
|
| 280 |
+
"expiresAt": 0,
|
| 281 |
+
}
|
| 282 |
+
}),
|
| 283 |
+
encoding="utf-8",
|
| 284 |
+
)
|
| 285 |
+
server_state_path = hermes_home / "fake-token-server.json"
|
| 286 |
+
server_state_path.write_text(
|
| 287 |
+
json.dumps({"calls": [], "spent": [], "rotation": 0}),
|
| 288 |
+
encoding="utf-8",
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
profile_homes = [hermes_home / "profile-a", hermes_home / "profile-b"]
|
| 292 |
+
for profile_home in profile_homes:
|
| 293 |
+
profile_home.mkdir(parents=True)
|
| 294 |
+
(profile_home / "auth.json").write_text(
|
| 295 |
+
json.dumps({
|
| 296 |
+
"version": 1,
|
| 297 |
+
"providers": {},
|
| 298 |
+
# claude_code is a borrowed source; its raw tokens must not
|
| 299 |
+
# be persisted in a profile pool. Each worker constructs the
|
| 300 |
+
# runtime entry from the shared credential source below.
|
| 301 |
+
"credential_pool": {},
|
| 302 |
+
}),
|
| 303 |
+
encoding="utf-8",
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
ctx = mp.get_context("spawn")
|
| 307 |
+
start_event = ctx.Event()
|
| 308 |
+
result_queue = ctx.Queue()
|
| 309 |
+
processes = [
|
| 310 |
+
ctx.Process(
|
| 311 |
+
target=_process_claude_code_refresh_worker,
|
| 312 |
+
args=(
|
| 313 |
+
str(profile_home),
|
| 314 |
+
str(shared_credentials_path),
|
| 315 |
+
str(server_state_path),
|
| 316 |
+
start_event,
|
| 317 |
+
result_queue,
|
| 318 |
+
),
|
| 319 |
+
)
|
| 320 |
+
for profile_home in profile_homes
|
| 321 |
+
]
|
| 322 |
+
|
| 323 |
+
messages = []
|
| 324 |
+
try:
|
| 325 |
+
for process in processes:
|
| 326 |
+
process.start()
|
| 327 |
+
|
| 328 |
+
ready_deadline = time.monotonic() + 20.0
|
| 329 |
+
while len([m for m in messages if m.get("kind") == "ready"]) < len(processes):
|
| 330 |
+
remaining = max(0.1, ready_deadline - time.monotonic())
|
| 331 |
+
if remaining <= 0.1:
|
| 332 |
+
break
|
| 333 |
+
try:
|
| 334 |
+
messages.append(result_queue.get(timeout=remaining))
|
| 335 |
+
except queue.Empty:
|
| 336 |
+
break
|
| 337 |
+
assert len([m for m in messages if m.get("kind") == "ready"]) == len(processes), (
|
| 338 |
+
f"not all refresh workers reached the start barrier: {messages!r}"
|
| 339 |
+
)
|
| 340 |
+
start_event.set()
|
| 341 |
+
|
| 342 |
+
for process in processes:
|
| 343 |
+
process.join(timeout=30)
|
| 344 |
+
assert not [process for process in processes if process.is_alive()], (
|
| 345 |
+
"a profile refresh worker did not finish; possible shared-lock deadlock"
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
result_deadline = time.monotonic() + 5.0
|
| 349 |
+
results = [m for m in messages if m.get("kind") == "result"]
|
| 350 |
+
while len(results) < len(processes) and time.monotonic() < result_deadline:
|
| 351 |
+
try:
|
| 352 |
+
message = result_queue.get(timeout=0.5)
|
| 353 |
+
except queue.Empty:
|
| 354 |
+
break
|
| 355 |
+
messages.append(message)
|
| 356 |
+
if message.get("kind") == "result":
|
| 357 |
+
results.append(message)
|
| 358 |
+
finally:
|
| 359 |
+
start_event.set()
|
| 360 |
+
for process in processes:
|
| 361 |
+
process.join(timeout=2)
|
| 362 |
+
for process in processes:
|
| 363 |
+
if process.is_alive():
|
| 364 |
+
process.kill()
|
| 365 |
+
process.join(timeout=5)
|
| 366 |
+
result_queue.close()
|
| 367 |
+
result_queue.join_thread()
|
| 368 |
+
|
| 369 |
+
assert len(results) == len(processes), f"missing process results: {messages!r}"
|
| 370 |
+
assert all(result.get("ok") for result in results), results
|
| 371 |
+
assert {result.get("refresh_token") for result in results} == {"process-refresh-1"}
|
| 372 |
+
assert all((profile_home / "auth.lock").exists() for profile_home in profile_homes)
|
| 373 |
+
assert shared_credentials_path.with_suffix(".lock").exists()
|
| 374 |
+
|
| 375 |
+
server_state = json.loads(server_state_path.read_text(encoding="utf-8"))
|
| 376 |
+
assert server_state["calls"] == ["stale-rt"], (
|
| 377 |
+
"the shared stale refresh token must be POSTed exactly once across "
|
| 378 |
+
f"distinct profiles, got {server_state['calls']!r}"
|
| 379 |
+
)
|
| 380 |
+
assert server_state["spent"] == ["stale-rt"]
|
| 381 |
+
|
| 382 |
+
shared_credentials = json.loads(shared_credentials_path.read_text(encoding="utf-8"))
|
| 383 |
+
assert shared_credentials["claudeAiOauth"]["refreshToken"] == "process-refresh-1"
|
| 384 |
+
for profile_home in profile_homes:
|
| 385 |
+
profile_text = (profile_home / "auth.json").read_text(encoding="utf-8")
|
| 386 |
+
assert "stale-rt" not in profile_text
|
| 387 |
+
assert "process-refresh-1" not in profile_text
|
tests/agent/test_anthropic_output_field_leak.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression: output-only SDK fields must not leak into Anthropic request input.
|
| 2 |
+
|
| 3 |
+
Reproduces HTTP 400 `messages.N.content.M.text.parsed_output: Extra inputs are
|
| 4 |
+
not permitted`. Anthropic SDK response blocks carry output-only attributes
|
| 5 |
+
(text blocks: `parsed_output`, `citations=None`; tool_use blocks: `caller`)
|
| 6 |
+
that the Messages *input* schema forbids. normalize_response captured blocks
|
| 7 |
+
verbatim via _to_plain_data and replayed them as input → 400.
|
| 8 |
+
|
| 9 |
+
Fix: whitelist input-permitted fields per block type at three points —
|
| 10 |
+
normalize_response capture, _sanitize_replay_block (ordered-blocks replay), and
|
| 11 |
+
_convert_content_part_to_anthropic (content-list replay).
|
| 12 |
+
"""
|
| 13 |
+
import pytest
|
| 14 |
+
from agent.anthropic_message_convert import _sanitize_replay_block, _convert_content_part_to_anthropic, _convert_assistant_message
|
| 15 |
+
|
| 16 |
+
FORBIDDEN = {"parsed_output", "caller"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _assert_clean(block):
|
| 20 |
+
"""No forbidden output-only key, and no null citations, anywhere."""
|
| 21 |
+
assert isinstance(block, dict)
|
| 22 |
+
for k in FORBIDDEN:
|
| 23 |
+
assert k not in block, f"forbidden field {k!r} survived: {block}"
|
| 24 |
+
if "citations" in block:
|
| 25 |
+
assert isinstance(block["citations"], list) and block["citations"], \
|
| 26 |
+
"citations must be a non-empty list if present (None/[] is input-invalid)"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TestSanitizeReplayBlock:
|
| 30 |
+
|
| 31 |
+
def test_tool_use_strips_caller(self):
|
| 32 |
+
poisoned = {"type": "tool_use", "id": "toolu_1", "name": "read_file",
|
| 33 |
+
"input": {"path": "a"}, "caller": {"type": "agent"}}
|
| 34 |
+
out = _sanitize_replay_block(poisoned)
|
| 35 |
+
_assert_clean(out)
|
| 36 |
+
assert out["name"] == "read_file" and out["input"] == {"path": "a"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_unknown_type_dropped(self):
|
| 41 |
+
assert _sanitize_replay_block({"type": "server_tool_use", "foo": 1}) is None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class TestContentPartConversion:
|
| 45 |
+
def test_stored_text_block_with_parsed_output_cleaned(self):
|
| 46 |
+
# The exact content.N.text.parsed_output failure shape.
|
| 47 |
+
part = {"type": "text", "text": "hello", "parsed_output": None, "citations": None}
|
| 48 |
+
out = _convert_content_part_to_anthropic(part)
|
| 49 |
+
_assert_clean(out)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class TestAssistantReplay:
|
| 53 |
+
def test_interleaved_blocks_replayed_clean_and_ordered(self):
|
| 54 |
+
m = {
|
| 55 |
+
"role": "assistant",
|
| 56 |
+
"anthropic_content_blocks": [
|
| 57 |
+
{"type": "thinking", "thinking": "plan", "signature": "s1"},
|
| 58 |
+
{"type": "text", "text": "doing it", "parsed_output": None, "citations": None},
|
| 59 |
+
{"type": "tool_use", "id": "toolu_1", "name": "read_file",
|
| 60 |
+
"input": {"path": "a"}, "caller": {"type": "agent"}},
|
| 61 |
+
],
|
| 62 |
+
}
|
| 63 |
+
out = _convert_assistant_message(m)
|
| 64 |
+
blocks = out["content"]
|
| 65 |
+
# order preserved
|
| 66 |
+
assert [b["type"] for b in blocks] == ["thinking", "text", "tool_use"]
|
| 67 |
+
# every block clean
|
| 68 |
+
for b in blocks:
|
| 69 |
+
_assert_clean(b)
|
| 70 |
+
# signature + tool fields intact
|
| 71 |
+
assert blocks[0]["signature"] == "s1"
|
| 72 |
+
assert blocks[2]["name"] == "read_file"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
raise SystemExit(pytest.main([__file__, "-v"]))
|
tests/agent/test_anthropic_truncation_continuation.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression test for anthropic_messages truncation continuation.
|
| 2 |
+
|
| 3 |
+
When an Anthropic response hits ``stop_reason: max_tokens`` (mapped to
|
| 4 |
+
``finish_reason == 'length'`` in run_agent), the agent must retry with
|
| 5 |
+
a continuation prompt — the same behavior it has always had for
|
| 6 |
+
chat_completions and bedrock_converse. Before this PR, the
|
| 7 |
+
``if self.api_mode in ('chat_completions', 'bedrock_converse'):`` guard
|
| 8 |
+
silently dropped Anthropic-wire truncations on the floor, returning a
|
| 9 |
+
half-finished response with no retry.
|
| 10 |
+
|
| 11 |
+
We don't exercise the full agent loop here (it's 3000 lines of inference,
|
| 12 |
+
streaming, plugin hooks, etc.) — instead we verify the normalization
|
| 13 |
+
adapter produces exactly the shape the continuation block now consumes.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from types import SimpleNamespace
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _make_anthropic_text_block(text: str) -> SimpleNamespace:
|
| 24 |
+
return SimpleNamespace(type="text", text=text)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _make_anthropic_tool_use_block(name: str = "my_tool") -> SimpleNamespace:
|
| 28 |
+
return SimpleNamespace(
|
| 29 |
+
type="tool_use",
|
| 30 |
+
id="toolu_01",
|
| 31 |
+
name=name,
|
| 32 |
+
input={"foo": "bar"},
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _make_anthropic_response(blocks, stop_reason: str = "max_tokens"):
|
| 37 |
+
return SimpleNamespace(
|
| 38 |
+
id="msg_01",
|
| 39 |
+
type="message",
|
| 40 |
+
role="assistant",
|
| 41 |
+
model="claude-sonnet-4-6",
|
| 42 |
+
content=blocks,
|
| 43 |
+
stop_reason=stop_reason,
|
| 44 |
+
stop_sequence=None,
|
| 45 |
+
usage=SimpleNamespace(input_tokens=100, output_tokens=200),
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TestTruncatedAnthropicResponseNormalization:
|
| 50 |
+
"""AnthropicTransport.normalize_response() gives us the shape _build_assistant_message expects."""
|
| 51 |
+
|
| 52 |
+
def test_text_only_truncation_produces_text_content_no_tool_calls(self):
|
| 53 |
+
"""Pure-text Anthropic truncation → continuation path should fire."""
|
| 54 |
+
from agent.transports import get_transport
|
| 55 |
+
|
| 56 |
+
response = _make_anthropic_response(
|
| 57 |
+
[_make_anthropic_text_block("partial response that was cut off")]
|
| 58 |
+
)
|
| 59 |
+
nr = get_transport("anthropic_messages").normalize_response(response)
|
| 60 |
+
|
| 61 |
+
# The continuation block checks these two attributes:
|
| 62 |
+
# assistant_message.content → appended to truncated_response_parts
|
| 63 |
+
# assistant_message.tool_calls → guards the text-retry branch
|
| 64 |
+
assert nr.content is not None
|
| 65 |
+
assert "partial response" in nr.content
|
| 66 |
+
assert not nr.tool_calls, (
|
| 67 |
+
"Pure-text truncation must have no tool_calls so the text-continuation "
|
| 68 |
+
"branch (not the tool-retry branch) fires"
|
| 69 |
+
)
|
| 70 |
+
assert nr.finish_reason == "length", "max_tokens stop_reason must map to OpenAI-style 'length'"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_empty_content_does_not_crash(self):
|
| 74 |
+
"""Empty response.content — defensive: treat as a truncation with no text."""
|
| 75 |
+
from agent.transports import get_transport
|
| 76 |
+
|
| 77 |
+
response = _make_anthropic_response([])
|
| 78 |
+
nr = get_transport("anthropic_messages").normalize_response(response)
|
| 79 |
+
# Depending on the adapter, content may be "" or None — both are
|
| 80 |
+
# acceptable; what matters is no exception.
|
| 81 |
+
assert nr is not None
|
| 82 |
+
assert not nr.tool_calls
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class TestContinuationLogicBranching:
|
| 86 |
+
"""Symbolic check that the api_mode gate now includes anthropic_messages."""
|
| 87 |
+
|
| 88 |
+
@pytest.mark.parametrize("api_mode", ["chat_completions", "bedrock_converse", "anthropic_messages"])
|
| 89 |
+
def test_all_three_api_modes_hit_continuation_branch(self, api_mode):
|
| 90 |
+
# The guard in run_agent.py is:
|
| 91 |
+
# if self.api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages"):
|
| 92 |
+
assert api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}
|
| 93 |
+
|
| 94 |
+
def test_codex_responses_still_excluded(self):
|
| 95 |
+
# codex_responses has its own truncation path (not continuation-based)
|
| 96 |
+
# and should NOT be routed through the shared block.
|
| 97 |
+
assert "codex_responses" not in {"chat_completions", "bedrock_converse", "anthropic_messages"}
|
tests/agent/test_api_content_row_addressed_backfill.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Row-addressed ``api_content`` backfill (NousResearch/hermes-agent#102194).
|
| 2 |
+
|
| 3 |
+
The sidecar is stamped by the turn prologue and normally reaches the DB in the
|
| 4 |
+
same INSERT as the clean content (the crash persist runs after the stamp). When
|
| 5 |
+
another writer materialized the current turn's user row FIRST — in-place
|
| 6 |
+
preflight compaction, or a close/early flush that raced the prologue — that
|
| 7 |
+
insert never happens: the crash persist marker-skips the message and the row
|
| 8 |
+
keeps ``api_content = NULL``, so the next turn replays clean content and the
|
| 9 |
+
request prefix diverges exactly at that message.
|
| 10 |
+
|
| 11 |
+
The prologue therefore backfills, but only when a row provably exists for THIS
|
| 12 |
+
dict. ``_row_id`` is that proof and that address: both early writers stamp it on
|
| 13 |
+
the live message (``_insert_message_rows`` directly, ``sync_flushed_message_markers``
|
| 14 |
+
after the batch commit). A positional "newest active user row" update cannot be
|
| 15 |
+
substituted for it — a repeated user turn ("ok", "y", "continue") makes the
|
| 16 |
+
previous turn's row compare equal on content, and the backfill would overwrite
|
| 17 |
+
that turn's sidecar with this turn's bytes.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import types
|
| 23 |
+
from unittest.mock import MagicMock, patch
|
| 24 |
+
|
| 25 |
+
import pytest
|
| 26 |
+
|
| 27 |
+
from agent.session_persistence import SessionPersistenceMixin
|
| 28 |
+
from agent.turn_context import _stamp_api_content_sidecar, compose_user_api_content
|
| 29 |
+
from hermes_state import SessionDB
|
| 30 |
+
from tests.agent.test_api_content_sidecar import _FakeAgent, _build
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TestSetMessageApiContent:
|
| 34 |
+
"""The store primitive: addressed by row id, guarded on the rest."""
|
| 35 |
+
|
| 36 |
+
def _open(self, tmp_path):
|
| 37 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 38 |
+
db.create_session("s1", source="cli")
|
| 39 |
+
return db
|
| 40 |
+
|
| 41 |
+
def test_older_identical_row_is_untouched(self, tmp_path):
|
| 42 |
+
"""Two user turns with the same text — the repeated-"ok" shape.
|
| 43 |
+
|
| 44 |
+
Addressing the row makes the older turn's sidecar unreachable; the
|
| 45 |
+
positional helper cannot tell them apart (asserted on the same DB).
|
| 46 |
+
"""
|
| 47 |
+
db = self._open(tmp_path)
|
| 48 |
+
try:
|
| 49 |
+
db.append_message("s1", "user", content="ok", api_content="ok\n\nTURN-1")
|
| 50 |
+
db.append_message("s1", "assistant", content="reply")
|
| 51 |
+
db.append_message("s1", "user", content="ok")
|
| 52 |
+
rows = db.get_messages("s1")
|
| 53 |
+
turn_1_id, turn_2_id = rows[0]["id"], rows[2]["id"]
|
| 54 |
+
|
| 55 |
+
assert db.set_message_api_content("s1", turn_2_id, "ok", "ok\n\nTURN-2") == 1
|
| 56 |
+
rows = {r["id"]: r for r in db.get_messages("s1")}
|
| 57 |
+
assert rows[turn_1_id]["api_content"] == "ok\n\nTURN-1"
|
| 58 |
+
assert rows[turn_2_id]["api_content"] == "ok\n\nTURN-2"
|
| 59 |
+
|
| 60 |
+
finally:
|
| 61 |
+
db.close()
|
| 62 |
+
|
| 63 |
+
def test_guards_refuse_wrong_session_or_mismatched_content_or_archived_row(self, tmp_path):
|
| 64 |
+
db = self._open(tmp_path)
|
| 65 |
+
try:
|
| 66 |
+
db.create_session("s2", source="cli")
|
| 67 |
+
db.append_message("s1", "user", content="hello")
|
| 68 |
+
row_id = db.get_messages("s1")[0]["id"]
|
| 69 |
+
|
| 70 |
+
assert db.set_message_api_content("s2", row_id, "hello", "x") == 0
|
| 71 |
+
assert db.set_message_api_content("s1", row_id, "other", "x") == 0
|
| 72 |
+
assert db.set_message_api_content("s1", row_id + 999, "hello", "x") == 0
|
| 73 |
+
assert db.get_messages("s1")[0]["api_content"] is None
|
| 74 |
+
|
| 75 |
+
# Archived by compaction: active = 0, so the row is off limits.
|
| 76 |
+
db.archive_and_compact("s1", [{"role": "user", "content": "hello"}])
|
| 77 |
+
assert db.set_message_api_content("s1", row_id, "hello", "x") == 0
|
| 78 |
+
finally:
|
| 79 |
+
db.close()
|
| 80 |
+
|
| 81 |
+
class TestPrologueRowAddressedBackfill:
|
| 82 |
+
"""The prologue gate: backfill iff a durable row exists for this dict."""
|
| 83 |
+
|
| 84 |
+
def test_no_row_id_and_no_compaction_writes_nothing(self):
|
| 85 |
+
"""The normal path: the row does not exist yet and the crash persist
|
| 86 |
+
writes it WITH the sidecar. A backfill here has no row to address and
|
| 87 |
+
would have to guess — so it must not run at all."""
|
| 88 |
+
agent = _FakeAgent()
|
| 89 |
+
agent._session_db = MagicMock()
|
| 90 |
+
with patch(
|
| 91 |
+
"hermes_cli.plugins.invoke_hook",
|
| 92 |
+
return_value=[{"context": "PLUGIN-CTX"}],
|
| 93 |
+
):
|
| 94 |
+
ctx = _build(agent)
|
| 95 |
+
|
| 96 |
+
assert (
|
| 97 |
+
ctx.messages[ctx.current_turn_user_idx]["api_content"]
|
| 98 |
+
== "hello\n\nPLUGIN-CTX"
|
| 99 |
+
)
|
| 100 |
+
agent._session_db.set_message_api_content.assert_not_called()
|
| 101 |
+
agent._session_db.set_latest_user_api_content.assert_not_called()
|
| 102 |
+
|
| 103 |
+
class _RealPersistenceAgent(SessionPersistenceMixin, _FakeAgent):
|
| 104 |
+
"""Stand-in agent with the real SessionPersistenceMixin flush implementation."""
|
| 105 |
+
|
| 106 |
+
def __init__(self, db=None, sid="s1"):
|
| 107 |
+
_FakeAgent.__init__(self)
|
| 108 |
+
self._session_db = db
|
| 109 |
+
self.session_id = sid
|
| 110 |
+
self._session_db_created = True
|
| 111 |
+
self._flushed_db_message_ids = set()
|
| 112 |
+
self._last_flushed_db_idx = 0
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class TestRealEarlyFlushAndOverrideLifecycle:
|
| 116 |
+
"""End-to-end tests exercising real database flushes and API-only overrides."""
|
| 117 |
+
|
| 118 |
+
def test_real_close_flush_syncs_row_id_and_prologue_backfills(self, tmp_path):
|
| 119 |
+
"""Proof that the real _flush_messages_to_session_db path syncs _row_id onto
|
| 120 |
+
the live dict (via sync_flushed_message_markers) and the prologue backfills it."""
|
| 121 |
+
path = tmp_path / "state.db"
|
| 122 |
+
db = SessionDB(db_path=path)
|
| 123 |
+
sid = "sess-real-flush"
|
| 124 |
+
db.create_session(sid, source="cli")
|
| 125 |
+
try:
|
| 126 |
+
agent = _RealPersistenceAgent(db, sid)
|
| 127 |
+
|
| 128 |
+
staged = {"role": "user", "content": "hello"}
|
| 129 |
+
agent._pending_cli_user_message = staged
|
| 130 |
+
|
| 131 |
+
# Simulate the early/close flush racing the prologue
|
| 132 |
+
flushed = agent._flush_messages_to_session_db([staged], None)
|
| 133 |
+
assert flushed is True
|
| 134 |
+
assert staged.get("_db_persisted") is True
|
| 135 |
+
assert isinstance(staged.get("_row_id"), int)
|
| 136 |
+
assert staged["_row_id"] == db.get_messages(sid)[-1]["id"]
|
| 137 |
+
# At this point, the row in SQLite has api_content = None
|
| 138 |
+
assert db.get_messages(sid)[-1]["api_content"] is None
|
| 139 |
+
|
| 140 |
+
# Now build_turn_context runs
|
| 141 |
+
with patch(
|
| 142 |
+
"hermes_cli.plugins.invoke_hook",
|
| 143 |
+
return_value=[{"context": "PLUGIN-CTX"}],
|
| 144 |
+
):
|
| 145 |
+
ctx = _build(agent)
|
| 146 |
+
|
| 147 |
+
expected = compose_user_api_content("hello", "", "PLUGIN-CTX")
|
| 148 |
+
assert ctx.messages[ctx.current_turn_user_idx]["api_content"] == expected
|
| 149 |
+
# Backfilled to the exact row in SQLite!
|
| 150 |
+
assert db.get_messages(sid)[-1]["api_content"] == expected
|
| 151 |
+
finally:
|
| 152 |
+
db.close()
|
| 153 |
+
|
| 154 |
+
def test_pre_flushed_api_only_turn_without_injections_preserves_sidecar(self, tmp_path):
|
| 155 |
+
"""[ehz0ah bug 1]: Pre-flushed clean input where the API turn has an API-only
|
| 156 |
+
variant (e.g. voice prefix) and NO memory/plugin injection is composed.
|
| 157 |
+
compose_user_api_content returns None, but the differing API-only bytes must
|
| 158 |
+
be preserved as api_content and backfilled onto the row."""
|
| 159 |
+
path = tmp_path / "state.db"
|
| 160 |
+
db = SessionDB(db_path=path)
|
| 161 |
+
sid = "sess-api-only-no-inj"
|
| 162 |
+
db.create_session(sid, source="cli")
|
| 163 |
+
try:
|
| 164 |
+
agent = _RealPersistenceAgent(db, sid)
|
| 165 |
+
|
| 166 |
+
clean_text = "hello"
|
| 167 |
+
api_text = "[voice] hello"
|
| 168 |
+
|
| 169 |
+
staged = {"role": "user", "content": clean_text}
|
| 170 |
+
agent._pending_cli_user_message = staged
|
| 171 |
+
agent._flush_messages_to_session_db([staged], None)
|
| 172 |
+
assert staged.get("_row_id") is not None
|
| 173 |
+
|
| 174 |
+
# Worker resumes with API-facing message and clean persist override
|
| 175 |
+
with patch("hermes_cli.plugins.invoke_hook", return_value=[]):
|
| 176 |
+
ctx = _build(
|
| 177 |
+
agent,
|
| 178 |
+
user_message=api_text,
|
| 179 |
+
persist_user_message=clean_text,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# Live user message has the API text and api_content
|
| 183 |
+
turn_msg = ctx.messages[ctx.current_turn_user_idx]
|
| 184 |
+
assert turn_msg["content"] == api_text
|
| 185 |
+
assert turn_msg["api_content"] == api_text
|
| 186 |
+
|
| 187 |
+
# Database row has clean text as content, but api_text as api_content!
|
| 188 |
+
db_rows = db.get_messages(sid)
|
| 189 |
+
assert len(db_rows) == 1
|
| 190 |
+
assert db_rows[0]["content"] == clean_text
|
| 191 |
+
assert db_rows[0]["api_content"] == api_text
|
| 192 |
+
|
| 193 |
+
# Replay via get_messages_as_conversation preserves clean content
|
| 194 |
+
# alongside the api_content sidecar.
|
| 195 |
+
conv = db.get_messages_as_conversation(sid)
|
| 196 |
+
assert conv[0]["content"] == clean_text
|
| 197 |
+
assert conv[0]["api_content"] == api_text
|
| 198 |
+
from agent.turn_context import substitute_api_content
|
| 199 |
+
substitute_api_content(conv[0])
|
| 200 |
+
assert conv[0]["content"] == api_text
|
| 201 |
+
finally:
|
| 202 |
+
db.close()
|
| 203 |
+
|
| 204 |
+
def test_repeated_prompt_protected_against_positional_overwrite(self, tmp_path):
|
| 205 |
+
"""Repeated prompts 'ok' across turns: Turn 1 has sidecar, Turn 2 is pre-flushed.
|
| 206 |
+
Row-addressed backfill on Turn 2 never mutates Turn 1's stored sidecar."""
|
| 207 |
+
path = tmp_path / "state.db"
|
| 208 |
+
db = SessionDB(db_path=path)
|
| 209 |
+
sid = "sess-repeated-ok"
|
| 210 |
+
db.create_session(sid, source="cli")
|
| 211 |
+
try:
|
| 212 |
+
# Turn 1
|
| 213 |
+
db.append_message(sid, "user", content="ok", api_content="ok\n\nTURN-1-CTX")
|
| 214 |
+
db.append_message(sid, "assistant", content="acknowledged")
|
| 215 |
+
t1_user_row = db.get_messages(sid)[0]
|
| 216 |
+
|
| 217 |
+
# Turn 2: staged and pre-flushed
|
| 218 |
+
staged_t2 = {"role": "user", "content": "ok"}
|
| 219 |
+
agent = _RealPersistenceAgent(db, sid)
|
| 220 |
+
agent._pending_cli_user_message = staged_t2
|
| 221 |
+
|
| 222 |
+
agent._flush_messages_to_session_db([staged_t2], None)
|
| 223 |
+
t2_user_row = db.get_messages(sid)[2]
|
| 224 |
+
assert t2_user_row["id"] != t1_user_row["id"]
|
| 225 |
+
assert t2_user_row["api_content"] is None
|
| 226 |
+
|
| 227 |
+
# Prologue backfills Turn 2
|
| 228 |
+
with patch(
|
| 229 |
+
"hermes_cli.plugins.invoke_hook",
|
| 230 |
+
return_value=[{"context": "TURN-2-CTX"}],
|
| 231 |
+
):
|
| 232 |
+
_build(agent, user_message="ok")
|
| 233 |
+
|
| 234 |
+
rows = {r["id"]: r for r in db.get_messages(sid)}
|
| 235 |
+
assert rows[t1_user_row["id"]]["api_content"] == "ok\n\nTURN-1-CTX"
|
| 236 |
+
assert rows[t2_user_row["id"]]["api_content"] == "ok\n\nTURN-2-CTX"
|
| 237 |
+
finally:
|
| 238 |
+
db.close()
|
tests/agent/test_arcee_trinity_overrides.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Arcee Trinity Large Thinking per-model overrides.
|
| 2 |
+
|
| 3 |
+
Arcee Trinity Large Thinking is a reasoning model that wants:
|
| 4 |
+
- Fixed temperature=0.5 (vs the global default)
|
| 5 |
+
- Compression threshold=0.75 (delay compression to preserve reasoning context)
|
| 6 |
+
|
| 7 |
+
The helpers must match the bare model name, including when it arrives via
|
| 8 |
+
OpenRouter as ``arcee-ai/trinity-large-thinking``, but must NOT hit sibling
|
| 9 |
+
Arcee models like trinity-large-preview or trinity-mini.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from agent.agent_init import _resolve_compression_threshold
|
| 17 |
+
from agent.auxiliary_client import (
|
| 18 |
+
_compression_threshold_for_model,
|
| 19 |
+
_fixed_temperature_for_model,
|
| 20 |
+
_is_arcee_trinity_thinking,
|
| 21 |
+
_is_codex_gpt54_or_gpt55,
|
| 22 |
+
_is_codex_spark,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.mark.parametrize(
|
| 27 |
+
"model",
|
| 28 |
+
[
|
| 29 |
+
"trinity-large-thinking",
|
| 30 |
+
"arcee-ai/trinity-large-thinking",
|
| 31 |
+
"Arcee-AI/Trinity-Large-Thinking", # case-insensitive
|
| 32 |
+
" trinity-large-thinking ", # whitespace tolerant
|
| 33 |
+
],
|
| 34 |
+
)
|
| 35 |
+
def test_is_arcee_trinity_thinking_matches(model: str) -> None:
|
| 36 |
+
assert _is_arcee_trinity_thinking(model) is True
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_fixed_temperature_for_trinity_thinking() -> None:
|
| 42 |
+
assert _fixed_temperature_for_model("trinity-large-thinking") == 0.5
|
| 43 |
+
assert _fixed_temperature_for_model("arcee-ai/trinity-large-thinking") == 0.5
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_compression_threshold_default_none_for_other_models() -> None:
|
| 51 |
+
# None means "leave the user's config value unchanged".
|
| 52 |
+
assert _compression_threshold_for_model(None) is None
|
| 53 |
+
assert _compression_threshold_for_model("") is None
|
| 54 |
+
assert _compression_threshold_for_model("trinity-large-preview") is None
|
| 55 |
+
assert _compression_threshold_for_model("claude-sonnet-4.6") is None
|
| 56 |
+
assert _compression_threshold_for_model("kimi-k2") is None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
# Codex gpt-5.4 / gpt-5.5 compaction-threshold autoraise
|
| 61 |
+
#
|
| 62 |
+
# ChatGPT's Codex OAuth backend caps both families at a 272K window (verified
|
| 63 |
+
# live via the Codex /models resolver and per-slug fallback table). The default
|
| 64 |
+
# 50% compaction trigger would fire at ~136K — half the usable window — so this
|
| 65 |
+
# route raises the trigger to 85%. Only the Codex OAuth route is affected; the
|
| 66 |
+
# same slugs on OpenAI direct / OpenRouter / Copilot expose a larger window and
|
| 67 |
+
# keep the user's global threshold.
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@pytest.mark.parametrize(
|
| 76 |
+
"model",
|
| 77 |
+
[
|
| 78 |
+
"gpt-5", "gpt-5.55", "gpt-5.50", "gpt-5.45", "gpt-5.40",
|
| 79 |
+
"gpt-daybreak-blue-latest-mini", "", None,
|
| 80 |
+
],
|
| 81 |
+
)
|
| 82 |
+
def test_is_codex_gpt54_or_gpt55_rejects_non_54_55_models(model) -> None:
|
| 83 |
+
# Close numeric neighbours must NOT match — the prefix guards require a
|
| 84 |
+
# separator after "5.4" / "5.5" so e.g. gpt-5.45 and gpt-5.55 stay out.
|
| 85 |
+
assert _is_codex_gpt54_or_gpt55(model, "openai-codex") is False
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_compression_threshold_for_codex_gpt55() -> None:
|
| 89 |
+
assert _compression_threshold_for_model("gpt-5.4", "openai-codex") == 0.85
|
| 90 |
+
assert _compression_threshold_for_model("gpt-5.4-pro", "openai-codex") == 0.85
|
| 91 |
+
assert _compression_threshold_for_model("openai/gpt-5.4", "openai-codex") == 0.85
|
| 92 |
+
assert _compression_threshold_for_model("gpt-5.5", "openai-codex") == 0.85
|
| 93 |
+
assert _compression_threshold_for_model("gpt-5.5-pro", "openai-codex") == 0.85
|
| 94 |
+
assert _compression_threshold_for_model("openai/gpt-5.5", "openai-codex") == 0.85
|
| 95 |
+
assert _is_codex_gpt54_or_gpt55("gpt-daybreak-blue-latest", "openai-codex") is True
|
| 96 |
+
assert _compression_threshold_for_model("gpt-daybreak-blue-latest", "openai-codex") == 0.85
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@pytest.mark.parametrize(
|
| 100 |
+
"model",
|
| 101 |
+
[
|
| 102 |
+
"gpt-5.6-sol-900k",
|
| 103 |
+
"gpt-5.6-terra-900k",
|
| 104 |
+
"gpt-5.6-luna-900k",
|
| 105 |
+
"gpt-5.4-900k",
|
| 106 |
+
"gpt-daybreak-blue-latest-900k",
|
| 107 |
+
"openai/gpt-5.6-sol-900k",
|
| 108 |
+
],
|
| 109 |
+
)
|
| 110 |
+
def test_900k_variants_keep_global_threshold(model) -> None:
|
| 111 |
+
"""The 85% autoraise compensates for the small 272K window; ``-900k``
|
| 112 |
+
opt-in variants run at ~900K, so they keep the user's global
|
| 113 |
+
``compression.threshold`` (default 50%) — no override returned."""
|
| 114 |
+
assert _is_codex_gpt54_or_gpt55(model, "openai-codex") is False
|
| 115 |
+
assert _compression_threshold_for_model(model, "openai-codex") is None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_base_slugs_still_autoraised_alongside_900k_variants() -> None:
|
| 119 |
+
"""Sanity pair: the base slug autoraises while its variant does not."""
|
| 120 |
+
assert _compression_threshold_for_model("gpt-5.6-sol", "openai-codex") == 0.85
|
| 121 |
+
assert _compression_threshold_for_model("gpt-5.6-sol-900k", "openai-codex") is None
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# Codex gpt-5.3-codex-spark compaction-threshold autoraise
|
| 132 |
+
#
|
| 133 |
+
# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a
|
| 134 |
+
# native 128K context window. The default 50% compaction trigger would fire
|
| 135 |
+
# at ~64K — wasting half the usable window, often before the session has
|
| 136 |
+
# accumulated enough turns to summarize meaningfully. This route raises the
|
| 137 |
+
# trigger to 70% (~90K) to preserve more raw context while leaving ~38K
|
| 138 |
+
# headroom before the 128K hard limit.
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@pytest.mark.parametrize(
|
| 147 |
+
"model",
|
| 148 |
+
[
|
| 149 |
+
"gpt-5.5", # different family
|
| 150 |
+
"gpt-5.3-codex", # sibling, not spark
|
| 151 |
+
"gpt-5.3", # bare 5.3, not spark
|
| 152 |
+
"gpt-5.3-codex-spark-mini", # hypothetical variant — not matched yet
|
| 153 |
+
"", None,
|
| 154 |
+
],
|
| 155 |
+
)
|
| 156 |
+
def test_is_codex_spark_rejects_non_spark_models(model) -> None:
|
| 157 |
+
assert _is_codex_spark(model, "openai-codex") is False
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ── _resolve_compression_threshold (init_agent application logic) ────────────
|
| 167 |
+
#
|
| 168 |
+
# The Codex overrides are *autoraises*: they raise the trigger (0.85 for the
|
| 169 |
+
# gpt-5.4/5.5 272K family, 0.70 for spark) but must never LOWER a higher
|
| 170 |
+
# user-configured global threshold.
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_resolve_no_override_keeps_global() -> None:
|
| 182 |
+
# No per-model override (model_cthresh is None) → global threshold, no notice.
|
| 183 |
+
effective, notice = _resolve_compression_threshold(
|
| 184 |
+
0.50, None, is_codex_autoraise=False
|
| 185 |
+
)
|
| 186 |
+
assert effective == 0.50
|
| 187 |
+
assert notice is None
|
| 188 |
+
|
| 189 |
+
|
tests/agent/test_aux_affordable_402_retry.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Credit-limited 402 handling: clamp to the affordable budget and retry.
|
| 2 |
+
|
| 3 |
+
Regression tests for the masoria stall (Aug 2026): compression fell back to
|
| 4 |
+
OpenRouter, which defaulted the omitted output cap to the model's full 65,536
|
| 5 |
+
window and rejected with ``402 ... can only afford 7117`` — three times in a
|
| 6 |
+
row — on an account whose balance easily covered a summary.
|
| 7 |
+
|
| 8 |
+
Two coordinated fixes:
|
| 9 |
+
|
| 10 |
+
1. ``_build_call_kwargs`` preserves an explicit ``max_tokens`` for OpenRouter
|
| 11 |
+
routes (salvage of PR #41055 by @liuhao1024, issue #41035).
|
| 12 |
+
2. ``_create_with_progress`` retries ONCE with the provider-stated affordable
|
| 13 |
+
budget when a 402 names one (pattern proven in closed PR #49785 for the
|
| 14 |
+
main loop; this is the auxiliary-path equivalent).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from types import SimpleNamespace
|
| 18 |
+
from unittest.mock import patch
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
|
| 22 |
+
from agent.auxiliary_client import (
|
| 23 |
+
_affordable_max_tokens_from_error,
|
| 24 |
+
_build_call_kwargs,
|
| 25 |
+
_create_with_progress,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class _Payment402(Exception):
|
| 30 |
+
status_code = 402
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
_OPENROUTER_402 = (
|
| 34 |
+
"Error code: 402 - {'error': {'message': 'This request requires more "
|
| 35 |
+
"credits, or fewer max_tokens. You requested up to 65536 tokens, but can "
|
| 36 |
+
"only afford 7117. To increase, visit https://openrouter.ai/settings/"
|
| 37 |
+
"credits and add more credits', 'code': 402}}"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TestAffordableExtraction:
|
| 42 |
+
def test_extracts_budget_minus_margin(self):
|
| 43 |
+
assert _affordable_max_tokens_from_error(_Payment402(_OPENROUTER_402)) == 7117 - 64
|
| 44 |
+
|
| 45 |
+
def test_plain_exhaustion_returns_none(self):
|
| 46 |
+
err = _Payment402("Error code: 402 - insufficient funds")
|
| 47 |
+
assert _affordable_max_tokens_from_error(err) is None
|
| 48 |
+
|
| 49 |
+
def test_tiny_budget_treated_as_exhaustion(self):
|
| 50 |
+
err = _Payment402("You requested up to 65536 tokens, but can only afford 100.")
|
| 51 |
+
assert _affordable_max_tokens_from_error(err) is None
|
| 52 |
+
|
| 53 |
+
def test_non_payment_error_returns_none(self):
|
| 54 |
+
assert _affordable_max_tokens_from_error(TimeoutError(
|
| 55 |
+
"Codex auxiliary Responses stream stalled: no new output for 60.0s"
|
| 56 |
+
)) is None
|
| 57 |
+
|
| 58 |
+
def test_comma_grouped_count(self):
|
| 59 |
+
err = _Payment402("can only afford 12,345 tokens")
|
| 60 |
+
assert _affordable_max_tokens_from_error(err) == 12345 - 64
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class _FlakyClient:
|
| 64 |
+
"""402s with the affordable message until max_tokens fits the budget."""
|
| 65 |
+
|
| 66 |
+
def __init__(self, affordable=7117):
|
| 67 |
+
self.calls = []
|
| 68 |
+
self._affordable = affordable
|
| 69 |
+
self.chat = SimpleNamespace(
|
| 70 |
+
completions=SimpleNamespace(create=self._create)
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def _create(self, **kwargs):
|
| 74 |
+
self.calls.append(kwargs)
|
| 75 |
+
cap = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens")
|
| 76 |
+
if cap is None or cap > self._affordable:
|
| 77 |
+
raise _Payment402(_OPENROUTER_402)
|
| 78 |
+
return SimpleNamespace(
|
| 79 |
+
choices=[SimpleNamespace(
|
| 80 |
+
index=0,
|
| 81 |
+
message=SimpleNamespace(role="assistant", content="summary"),
|
| 82 |
+
finish_reason="stop",
|
| 83 |
+
)],
|
| 84 |
+
model=kwargs.get("model"), usage=None,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TestCreateWithProgressAffordableRetry:
|
| 89 |
+
def test_uncapped_402_retries_once_with_affordable_cap(self):
|
| 90 |
+
client = _FlakyClient()
|
| 91 |
+
response = _create_with_progress(
|
| 92 |
+
client,
|
| 93 |
+
{"model": "google/gemini-3.6-flash",
|
| 94 |
+
"messages": [{"role": "user", "content": "summarize"}]},
|
| 95 |
+
"compression",
|
| 96 |
+
)
|
| 97 |
+
assert response.choices[0].message.content == "summary"
|
| 98 |
+
assert len(client.calls) == 2
|
| 99 |
+
assert client.calls[1]["max_tokens"] == 7117 - 64
|
| 100 |
+
|
| 101 |
+
def test_already_affordable_cap_does_not_spin(self):
|
| 102 |
+
"""A 402 on a request already within budget re-raises immediately."""
|
| 103 |
+
client = _FlakyClient(affordable=100) # everything 402s
|
| 104 |
+
with pytest.raises(_Payment402):
|
| 105 |
+
_create_with_progress(
|
| 106 |
+
client,
|
| 107 |
+
{"model": "m", "max_tokens": 5000,
|
| 108 |
+
"messages": [{"role": "user", "content": "x"}]},
|
| 109 |
+
"compression",
|
| 110 |
+
)
|
| 111 |
+
# 5000 > 7053 is false → within stated budget → single attempt.
|
| 112 |
+
assert len(client.calls) == 1
|
| 113 |
+
|
| 114 |
+
def test_plain_exhaustion_402_is_not_retried(self):
|
| 115 |
+
class _Broke:
|
| 116 |
+
def __init__(self):
|
| 117 |
+
self.calls = []
|
| 118 |
+
self.chat = SimpleNamespace(
|
| 119 |
+
completions=SimpleNamespace(create=self._create)
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
def _create(self, **kwargs):
|
| 123 |
+
self.calls.append(kwargs)
|
| 124 |
+
raise _Payment402("Error code: 402 - insufficient funds")
|
| 125 |
+
|
| 126 |
+
client = _Broke()
|
| 127 |
+
with pytest.raises(_Payment402):
|
| 128 |
+
_create_with_progress(
|
| 129 |
+
client,
|
| 130 |
+
{"model": "m", "messages": [{"role": "user", "content": "x"}]},
|
| 131 |
+
"compression",
|
| 132 |
+
)
|
| 133 |
+
assert len(client.calls) == 1
|
| 134 |
+
|
| 135 |
+
def test_retry_402_surfaces_without_spinning(self):
|
| 136 |
+
"""If the clamped retry 402s again, it propagates (single retry)."""
|
| 137 |
+
|
| 138 |
+
class _Always402:
|
| 139 |
+
def __init__(self):
|
| 140 |
+
self.calls = []
|
| 141 |
+
self.chat = SimpleNamespace(
|
| 142 |
+
completions=SimpleNamespace(create=self._create)
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
def _create(self, **kwargs):
|
| 146 |
+
self.calls.append(kwargs)
|
| 147 |
+
raise _Payment402(_OPENROUTER_402)
|
| 148 |
+
|
| 149 |
+
client = _Always402()
|
| 150 |
+
with pytest.raises(_Payment402):
|
| 151 |
+
_create_with_progress(
|
| 152 |
+
client,
|
| 153 |
+
{"model": "m", "messages": [{"role": "user", "content": "x"}]},
|
| 154 |
+
"compression",
|
| 155 |
+
)
|
| 156 |
+
assert len(client.calls) == 2
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class TestOpenRouterMaxTokensPreserved:
|
| 160 |
+
"""Salvage of PR #41055 (@liuhao1024): OpenRouter keeps an explicit cap."""
|
| 161 |
+
|
| 162 |
+
def test_openrouter_provider_includes_max_tokens(self):
|
| 163 |
+
kwargs = _build_call_kwargs(
|
| 164 |
+
provider="openrouter",
|
| 165 |
+
model="openai/gpt-4o-mini",
|
| 166 |
+
messages=[{"role": "user", "content": "test"}],
|
| 167 |
+
max_tokens=2000,
|
| 168 |
+
)
|
| 169 |
+
assert kwargs.get("max_tokens") == 2000 or kwargs.get("max_completion_tokens") == 2000
|
| 170 |
+
|
| 171 |
+
def test_openrouter_base_url_includes_max_tokens(self):
|
| 172 |
+
kwargs = _build_call_kwargs(
|
| 173 |
+
provider="openai",
|
| 174 |
+
model="google/gemini-3.6-flash",
|
| 175 |
+
messages=[{"role": "user", "content": "test"}],
|
| 176 |
+
max_tokens=2000,
|
| 177 |
+
base_url="https://openrouter.ai/api/v1",
|
| 178 |
+
)
|
| 179 |
+
assert kwargs.get("max_tokens") == 2000 or kwargs.get("max_completion_tokens") == 2000
|
| 180 |
+
|
| 181 |
+
def test_generic_provider_still_omits_max_tokens(self):
|
| 182 |
+
kwargs = _build_call_kwargs(
|
| 183 |
+
provider="openai",
|
| 184 |
+
model="gpt-4o-mini",
|
| 185 |
+
messages=[{"role": "user", "content": "test"}],
|
| 186 |
+
max_tokens=2000,
|
| 187 |
+
)
|
| 188 |
+
assert "max_tokens" not in kwargs and "max_completion_tokens" not in kwargs
|
| 189 |
+
|
| 190 |
+
def test_none_max_tokens_never_included_for_openrouter(self):
|
| 191 |
+
kwargs = _build_call_kwargs(
|
| 192 |
+
provider="openrouter",
|
| 193 |
+
model="test-model",
|
| 194 |
+
messages=[{"role": "user", "content": "test"}],
|
| 195 |
+
max_tokens=None,
|
| 196 |
+
)
|
| 197 |
+
assert "max_tokens" not in kwargs and "max_completion_tokens" not in kwargs
|
tests/agent/test_auxiliary_anthropic_pool_fallback_regression.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression: _try_anthropic() must fall back to the legacy token resolver
|
| 2 |
+
when the credential pool is present but has no usable entry.
|
| 3 |
+
|
| 4 |
+
Root cause (observed 2026-07-05): the pooled Anthropic OAuth entry expired and
|
| 5 |
+
its refresh_token was stale, so `_select_pool_entry("anthropic")` returned
|
| 6 |
+
`(True, None)` — pool exists, no selectable entry. The old `_try_anthropic`
|
| 7 |
+
hard-failed on that branch (`return None, None`), even though a perfectly
|
| 8 |
+
valid `ANTHROPIC_TOKEN` / credentials-file token was available. This wedged
|
| 9 |
+
every auxiliary task routed to Anthropic (goal judge → "no auxiliary client
|
| 10 |
+
configured"), while the MAIN session stayed healthy because it resolves the
|
| 11 |
+
env token directly.
|
| 12 |
+
|
| 13 |
+
openrouter (test_try_openrouter_pool_exhausted_falls_back_to_env) and codex
|
| 14 |
+
(TestBuildCodexClient.test_pool_without_selected_entry_falls_back_to_auth_store)
|
| 15 |
+
already fall through to their standalone credential on `(True, None)`. This
|
| 16 |
+
test pins the same invariant for anthropic so the three paths stay symmetric:
|
| 17 |
+
a temporarily dead pool entry must never hard-fail when a valid standalone
|
| 18 |
+
credential exists.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from unittest.mock import MagicMock, patch
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TestAnthropicPoolExhaustedFallsBackToEnv:
|
| 25 |
+
def test_pool_present_no_entry_falls_back_to_resolve_token(self, monkeypatch):
|
| 26 |
+
"""pool=(True, None) but a valid env token exists → client is built."""
|
| 27 |
+
monkeypatch.setenv("ANTHROPIC_TOKEN", "«redacted:sk-…»-oauth-token")
|
| 28 |
+
with patch(
|
| 29 |
+
"agent.auxiliary_client._select_pool_entry", return_value=(True, None)
|
| 30 |
+
), patch(
|
| 31 |
+
"agent.anthropic_adapter.build_anthropic_client"
|
| 32 |
+
) as mock_build:
|
| 33 |
+
mock_build.return_value = MagicMock()
|
| 34 |
+
from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient
|
| 35 |
+
|
| 36 |
+
client, model = _try_anthropic()
|
| 37 |
+
|
| 38 |
+
assert client is not None, (
|
| 39 |
+
"_try_anthropic must fall back to resolve_anthropic_token() when the "
|
| 40 |
+
"pool is present but has no usable entry (parity with openrouter/codex)"
|
| 41 |
+
)
|
| 42 |
+
assert isinstance(client, AnthropicAuxiliaryClient)
|
| 43 |
+
# Default aux model when none configured.
|
| 44 |
+
assert model == "claude-haiku-4-5-20251001"
|
| 45 |
+
# Must have used the env/legacy token, not a pooled entry.
|
| 46 |
+
assert mock_build.call_args.args[0] == "«redacted:sk-…»-oauth-token"
|
| 47 |
+
|
| 48 |
+
def test_pool_present_no_entry_and_no_token_still_returns_none(self, monkeypatch):
|
| 49 |
+
"""No pooled entry AND no resolvable token → clean (None, None), no crash."""
|
| 50 |
+
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
| 51 |
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
| 52 |
+
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
| 53 |
+
with patch(
|
| 54 |
+
"agent.auxiliary_client._select_pool_entry", return_value=(True, None)
|
| 55 |
+
), patch(
|
| 56 |
+
"agent.anthropic_credentials.resolve_anthropic_token", return_value=None
|
| 57 |
+
):
|
| 58 |
+
from agent.auxiliary_client import _try_anthropic
|
| 59 |
+
|
| 60 |
+
client, model = _try_anthropic()
|
| 61 |
+
|
| 62 |
+
assert client is None
|
| 63 |
+
assert model is None
|
| 64 |
+
|
| 65 |
+
def test_base_url_defaults_when_pool_present_but_no_entry(self, monkeypatch):
|
| 66 |
+
"""Falling through with pool_present=True must not crash on base_url
|
| 67 |
+
resolution (previously guarded by `if pool_present`)."""
|
| 68 |
+
monkeypatch.setenv("ANTHROPIC_TOKEN", "«redacted:sk-…»-oauth-token")
|
| 69 |
+
captured = {}
|
| 70 |
+
|
| 71 |
+
def _fake_build(token, base_url):
|
| 72 |
+
captured["base_url"] = base_url
|
| 73 |
+
return MagicMock()
|
| 74 |
+
|
| 75 |
+
with patch(
|
| 76 |
+
"agent.auxiliary_client._select_pool_entry", return_value=(True, None)
|
| 77 |
+
), patch(
|
| 78 |
+
"agent.anthropic_adapter.build_anthropic_client", side_effect=_fake_build
|
| 79 |
+
):
|
| 80 |
+
from agent.auxiliary_client import _try_anthropic
|
| 81 |
+
|
| 82 |
+
client, _model = _try_anthropic()
|
| 83 |
+
|
| 84 |
+
assert client is not None
|
| 85 |
+
assert captured["base_url"] == "https://api.anthropic.com"
|
tests/agent/test_auxiliary_client_azure_foundry.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for auxiliary client routing of the ``azure-foundry`` provider.
|
| 2 |
+
|
| 3 |
+
Covers the dedicated branch in ``agent.auxiliary_client.resolve_provider_client``
|
| 4 |
+
that delegates to :func:`hermes_cli.runtime_provider._resolve_azure_foundry_runtime`
|
| 5 |
+
instead of falling into the generic ``resolve_api_key_provider_credentials``
|
| 6 |
+
path (which only knows about ``AZURE_FOUNDRY_API_KEY`` and would 401 for
|
| 7 |
+
Entra ID users and miss ``model.base_url`` overrides for api-key users
|
| 8 |
+
with non-standard Foundry-projects endpoints).
|
| 9 |
+
|
| 10 |
+
Pinned scenarios:
|
| 11 |
+
|
| 12 |
+
* ``auth_mode: api_key`` → plain OpenAI client with the static string
|
| 13 |
+
key for ``chat_completions``.
|
| 14 |
+
* ``auth_mode: entra_id`` + ``chat_completions`` → plain OpenAI
|
| 15 |
+
client with a callable ``api_key`` (the bearer-token provider) —
|
| 16 |
+
confirms the callable survives the auxiliary path end-to-end.
|
| 17 |
+
* ``auth_mode: entra_id`` + GPT-5.x model → CodexAuxiliaryClient
|
| 18 |
+
wrapping the OpenAI client (api_mode auto-upgrades to
|
| 19 |
+
codex_responses).
|
| 20 |
+
* Anthropic-style + entra_id → rejected at the runtime resolver,
|
| 21 |
+
so the aux path returns ``(None, None)``.
|
| 22 |
+
* Failure path when no model is configured returns ``(None, None)``
|
| 23 |
+
cleanly so the auto chain falls through.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import sys
|
| 29 |
+
from types import SimpleNamespace
|
| 30 |
+
|
| 31 |
+
import pytest
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.fixture(autouse=True)
|
| 35 |
+
def _reset_credential_cache():
|
| 36 |
+
from agent.azure_identity_adapter import reset_credential_cache
|
| 37 |
+
reset_credential_cache()
|
| 38 |
+
yield
|
| 39 |
+
reset_credential_cache()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.fixture
|
| 43 |
+
def fake_azure_identity(monkeypatch):
|
| 44 |
+
"""Stand-in for azure.identity (keeps CI hermetic when the SDK is
|
| 45 |
+
not installed)."""
|
| 46 |
+
from agent import azure_identity_adapter as _adapter
|
| 47 |
+
|
| 48 |
+
last = {"scope": None}
|
| 49 |
+
|
| 50 |
+
def _provider(scope):
|
| 51 |
+
return lambda: f"jwt-for-{scope}"
|
| 52 |
+
|
| 53 |
+
fake_module = SimpleNamespace(
|
| 54 |
+
DefaultAzureCredential=lambda **kw: SimpleNamespace(
|
| 55 |
+
kwargs=kw,
|
| 56 |
+
get_token=lambda scope: SimpleNamespace(token="fake", expires_on=9999999999),
|
| 57 |
+
),
|
| 58 |
+
get_bearer_token_provider=lambda credential, scope: (
|
| 59 |
+
last.__setitem__("scope", scope),
|
| 60 |
+
_provider(scope),
|
| 61 |
+
)[-1],
|
| 62 |
+
)
|
| 63 |
+
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
|
| 64 |
+
monkeypatch.setitem(sys.modules, "azure.identity", fake_module)
|
| 65 |
+
return last
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@pytest.fixture
|
| 69 |
+
def patch_load_config(monkeypatch):
|
| 70 |
+
"""Helper to set model_cfg seen by _try_azure_foundry."""
|
| 71 |
+
def _apply(model_cfg):
|
| 72 |
+
monkeypatch.setattr(
|
| 73 |
+
"hermes_cli.config.load_config",
|
| 74 |
+
lambda: {"model": model_cfg},
|
| 75 |
+
)
|
| 76 |
+
monkeypatch.setattr(
|
| 77 |
+
"hermes_cli.config.load_config_readonly",
|
| 78 |
+
lambda: {"model": model_cfg},
|
| 79 |
+
)
|
| 80 |
+
return _apply
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# auth_mode: api_key (default) — regression for the legacy path
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TestAuxAzureFoundryApiKey:
|
| 89 |
+
def test_chat_completions_returns_plain_openai_client(self, monkeypatch, patch_load_config):
|
| 90 |
+
from agent.auxiliary_client import _try_azure_foundry
|
| 91 |
+
from openai import OpenAI as _OpenAI
|
| 92 |
+
|
| 93 |
+
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
|
| 94 |
+
patch_load_config({
|
| 95 |
+
"provider": "azure-foundry",
|
| 96 |
+
"base_url": "https://r.openai.azure.com/openai/v1",
|
| 97 |
+
"api_mode": "chat_completions",
|
| 98 |
+
"default": "gpt-4o",
|
| 99 |
+
})
|
| 100 |
+
client, resolved = _try_azure_foundry(model="gpt-4o")
|
| 101 |
+
assert client is not None
|
| 102 |
+
assert resolved == "gpt-4o"
|
| 103 |
+
assert isinstance(client, _OpenAI)
|
| 104 |
+
assert client.api_key == "sk-azure-static-key"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_no_key_returns_none(self, monkeypatch, patch_load_config):
|
| 108 |
+
from agent.auxiliary_client import _try_azure_foundry
|
| 109 |
+
|
| 110 |
+
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
|
| 111 |
+
patch_load_config({
|
| 112 |
+
"provider": "azure-foundry",
|
| 113 |
+
"base_url": "https://r.openai.azure.com/openai/v1",
|
| 114 |
+
"api_mode": "chat_completions",
|
| 115 |
+
"default": "gpt-4o",
|
| 116 |
+
})
|
| 117 |
+
client, resolved = _try_azure_foundry(model="gpt-4o")
|
| 118 |
+
assert client is None
|
| 119 |
+
assert resolved is None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
# auth_mode: entra_id — callable api_key survives end-to-end
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class TestAuxAzureFoundryEntra:
|
| 129 |
+
def test_callable_api_key_reaches_openai_constructor(
|
| 130 |
+
self, monkeypatch, fake_azure_identity, patch_load_config,
|
| 131 |
+
):
|
| 132 |
+
"""The token provider callable must arrive at ``OpenAI(api_key=...)``
|
| 133 |
+
intact — never stringified to ``"no-key-required"`` or to the
|
| 134 |
+
SDK-internal empty-string representation BEFORE we hand it off.
|
| 135 |
+
|
| 136 |
+
We assert on the public SDK contract (constructor receives the
|
| 137 |
+
callable) rather than ``client.api_key``, because OpenAI 2.24.0
|
| 138 |
+
stores callable api_keys in a private attribute and exposes
|
| 139 |
+
``client.api_key`` as ``""``. The SDK still calls the callable
|
| 140 |
+
per request to mint ``Authorization: Bearer <token>``; that
|
| 141 |
+
behaviour is the documented Microsoft/OpenAI contract we rely on.
|
| 142 |
+
"""
|
| 143 |
+
from agent import auxiliary_client as _aux
|
| 144 |
+
|
| 145 |
+
received = {}
|
| 146 |
+
|
| 147 |
+
class _FakeOpenAI:
|
| 148 |
+
def __init__(self, **kwargs):
|
| 149 |
+
received.update(kwargs)
|
| 150 |
+
# Mirror the fields downstream callers read.
|
| 151 |
+
self.api_key = kwargs.get("api_key", "")
|
| 152 |
+
self.base_url = kwargs.get("base_url", "")
|
| 153 |
+
|
| 154 |
+
monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI)
|
| 155 |
+
patch_load_config({
|
| 156 |
+
"provider": "azure-foundry",
|
| 157 |
+
"base_url": "https://r.openai.azure.com/openai/v1",
|
| 158 |
+
"api_mode": "chat_completions",
|
| 159 |
+
"auth_mode": "entra_id",
|
| 160 |
+
"default": "gpt-4o",
|
| 161 |
+
})
|
| 162 |
+
client, resolved = _aux._try_azure_foundry(model="gpt-4o")
|
| 163 |
+
assert client is not None
|
| 164 |
+
assert resolved == "gpt-4o"
|
| 165 |
+
# Public-contract assertion: the OpenAI SDK constructor saw the
|
| 166 |
+
# callable, exactly as Microsoft's Foundry sample requires.
|
| 167 |
+
assert callable(received["api_key"])
|
| 168 |
+
assert not isinstance(received["api_key"], str)
|
| 169 |
+
assert received["api_key"]().startswith("jwt-for-")
|
| 170 |
+
# Base URL forwarded verbatim (no /responses suffix stripping
|
| 171 |
+
# in this path — that's a separate concern handled by the
|
| 172 |
+
# runtime resolver only when the user re-saves config).
|
| 173 |
+
assert received["base_url"] == "https://r.openai.azure.com/openai/v1"
|
| 174 |
+
|
| 175 |
+
def test_codex_responses_with_entra_wraps_correctly(
|
| 176 |
+
self, monkeypatch, fake_azure_identity, patch_load_config,
|
| 177 |
+
):
|
| 178 |
+
"""GPT-5.x deployment on Entra ID — auto-upgraded to
|
| 179 |
+
codex_responses, wrapped in CodexAuxiliaryClient, callable
|
| 180 |
+
api_key handed to the underlying OpenAI SDK."""
|
| 181 |
+
from agent import auxiliary_client as _aux
|
| 182 |
+
|
| 183 |
+
received = {}
|
| 184 |
+
|
| 185 |
+
class _FakeOpenAI:
|
| 186 |
+
def __init__(self, **kwargs):
|
| 187 |
+
received.update(kwargs)
|
| 188 |
+
self.api_key = kwargs.get("api_key", "")
|
| 189 |
+
self.base_url = kwargs.get("base_url", "")
|
| 190 |
+
|
| 191 |
+
monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI)
|
| 192 |
+
patch_load_config({
|
| 193 |
+
"provider": "azure-foundry",
|
| 194 |
+
"base_url": "https://r.openai.azure.com/openai/v1",
|
| 195 |
+
"api_mode": "chat_completions",
|
| 196 |
+
"auth_mode": "entra_id",
|
| 197 |
+
"default": "gpt-5.4-mini",
|
| 198 |
+
})
|
| 199 |
+
client, resolved = _aux._try_azure_foundry(model="gpt-5.4-mini")
|
| 200 |
+
assert resolved == "gpt-5.4-mini"
|
| 201 |
+
assert isinstance(client, _aux.CodexAuxiliaryClient)
|
| 202 |
+
# The Codex wrapper received an OpenAI client built with the
|
| 203 |
+
# callable api_key — verify against the SDK constructor record,
|
| 204 |
+
# not the wrapper attribute (which mirrors the SDK's empty-
|
| 205 |
+
# string representation).
|
| 206 |
+
assert callable(received["api_key"])
|
| 207 |
+
assert received["api_key"]().startswith("jwt-for-")
|
| 208 |
+
|
| 209 |
+
def test_entra_anthropic_messages_uses_bearer_hook(
|
| 210 |
+
self, monkeypatch, fake_azure_identity, patch_load_config,
|
| 211 |
+
):
|
| 212 |
+
"""Entra ID + anthropic_messages: runtime returns a callable
|
| 213 |
+
api_key; ``_maybe_wrap_anthropic`` → ``build_anthropic_client``
|
| 214 |
+
detects the callable and installs the bearer-injecting httpx
|
| 215 |
+
event hook on a custom ``httpx.Client`` passed to the
|
| 216 |
+
Anthropic SDK via ``http_client=``."""
|
| 217 |
+
from agent import auxiliary_client as _aux
|
| 218 |
+
from agent import anthropic_adapter as _anthropic
|
| 219 |
+
|
| 220 |
+
received = {}
|
| 221 |
+
|
| 222 |
+
class _FakeOpenAI:
|
| 223 |
+
def __init__(self, **kwargs):
|
| 224 |
+
received["openai"] = kwargs
|
| 225 |
+
self.api_key = kwargs.get("api_key", "")
|
| 226 |
+
self.base_url = kwargs.get("base_url", "")
|
| 227 |
+
|
| 228 |
+
class _FakeAnthropicSDK:
|
| 229 |
+
Omit = object # the adapter omits the unused credential header via sdk.Omit()
|
| 230 |
+
|
| 231 |
+
class Anthropic:
|
| 232 |
+
def __init__(self, **kwargs):
|
| 233 |
+
received["anthropic"] = kwargs
|
| 234 |
+
|
| 235 |
+
monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI)
|
| 236 |
+
monkeypatch.setattr(_anthropic, "_get_anthropic_sdk", lambda: _FakeAnthropicSDK)
|
| 237 |
+
|
| 238 |
+
patch_load_config({
|
| 239 |
+
"provider": "azure-foundry",
|
| 240 |
+
"base_url": "https://r.services.ai.azure.com/anthropic",
|
| 241 |
+
"api_mode": "anthropic_messages",
|
| 242 |
+
"auth_mode": "entra_id",
|
| 243 |
+
"default": "claude-sonnet-4-5",
|
| 244 |
+
})
|
| 245 |
+
client, resolved = _aux._try_azure_foundry(model="claude-sonnet-4-5")
|
| 246 |
+
assert client is not None
|
| 247 |
+
assert resolved == "claude-sonnet-4-5"
|
| 248 |
+
# The Anthropic SDK constructor received a custom http_client
|
| 249 |
+
# (the bearer-injecting hook) and a placeholder auth_token.
|
| 250 |
+
anthropic_kwargs = received.get("anthropic") or {}
|
| 251 |
+
assert "http_client" in anthropic_kwargs, (
|
| 252 |
+
"build_anthropic_client must pass a custom http_client when "
|
| 253 |
+
"given a callable api_key, otherwise the SDK cannot mint "
|
| 254 |
+
"fresh tokens per request"
|
| 255 |
+
)
|
| 256 |
+
assert anthropic_kwargs.get("auth_token") == "entra-id-bearer-via-http-hook"
|
| 257 |
+
# Verify the http_client actually has our event hook installed.
|
| 258 |
+
http_client = anthropic_kwargs["http_client"]
|
| 259 |
+
hooks = getattr(http_client, "event_hooks", {})
|
| 260 |
+
assert "request" in hooks and len(hooks["request"]) >= 1
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# ---------------------------------------------------------------------------
|
| 264 |
+
# resolve_provider_client → azure-foundry dispatch
|
| 265 |
+
# ---------------------------------------------------------------------------
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
class TestResolveProviderClientAzureFoundry:
|
| 269 |
+
def test_dispatches_to_azure_branch_not_generic_api_key_path(
|
| 270 |
+
self, monkeypatch, fake_azure_identity, patch_load_config,
|
| 271 |
+
):
|
| 272 |
+
"""End-to-end: the public ``resolve_provider_client`` entry
|
| 273 |
+
point must take the dedicated azure-foundry branch, NOT the
|
| 274 |
+
generic api-key registry path that would call
|
| 275 |
+
``resolve_api_key_provider_credentials`` and return None for
|
| 276 |
+
Entra users."""
|
| 277 |
+
from agent import auxiliary_client as _aux
|
| 278 |
+
|
| 279 |
+
received = {}
|
| 280 |
+
|
| 281 |
+
class _FakeOpenAI:
|
| 282 |
+
def __init__(self, **kwargs):
|
| 283 |
+
received.update(kwargs)
|
| 284 |
+
self.api_key = kwargs.get("api_key", "")
|
| 285 |
+
self.base_url = kwargs.get("base_url", "")
|
| 286 |
+
|
| 287 |
+
monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI)
|
| 288 |
+
patch_load_config({
|
| 289 |
+
"provider": "azure-foundry",
|
| 290 |
+
"base_url": "https://r.openai.azure.com/openai/v1",
|
| 291 |
+
"api_mode": "chat_completions",
|
| 292 |
+
"auth_mode": "entra_id",
|
| 293 |
+
"default": "gpt-4o",
|
| 294 |
+
})
|
| 295 |
+
client, resolved = _aux.resolve_provider_client("azure-foundry", "gpt-4o")
|
| 296 |
+
assert client is not None
|
| 297 |
+
assert resolved == "gpt-4o"
|
| 298 |
+
# The callable made it through resolve_provider_client → _try_azure_foundry
|
| 299 |
+
# → OpenAI(api_key=...).
|
| 300 |
+
assert callable(received["api_key"])
|
| 301 |
+
|
| 302 |
+
def test_warns_and_returns_none_on_failure(
|
| 303 |
+
self, monkeypatch, patch_load_config, caplog,
|
| 304 |
+
):
|
| 305 |
+
"""When azure-foundry is requested but cannot be resolved
|
| 306 |
+
(e.g. no model + no key), we return (None, None) and log a
|
| 307 |
+
clear warning pointing at ``hermes doctor``."""
|
| 308 |
+
import logging
|
| 309 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 310 |
+
|
| 311 |
+
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
|
| 312 |
+
patch_load_config({
|
| 313 |
+
"provider": "azure-foundry",
|
| 314 |
+
"base_url": "https://r.openai.azure.com/openai/v1",
|
| 315 |
+
"api_mode": "chat_completions",
|
| 316 |
+
# No default → resolver yields no model → bail
|
| 317 |
+
})
|
| 318 |
+
with caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
|
| 319 |
+
client, resolved = resolve_provider_client("azure-foundry")
|
| 320 |
+
assert client is None
|
| 321 |
+
assert resolved is None
|
| 322 |
+
assert any(
|
| 323 |
+
"azure-foundry" in rec.message and "hermes doctor" in rec.message
|
| 324 |
+
for rec in caplog.records
|
| 325 |
+
)
|
tests/agent/test_auxiliary_concurrency.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for per-task concurrency limiting on auxiliary LLM calls (#23324)."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
from unittest.mock import MagicMock, AsyncMock, patch
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from agent.auxiliary_client import (
|
| 11 |
+
call_llm,
|
| 12 |
+
async_call_llm,
|
| 13 |
+
_acquire_sync_aux_semaphore,
|
| 14 |
+
_acquire_async_aux_semaphore,
|
| 15 |
+
_get_task_max_concurrency,
|
| 16 |
+
_reset_aux_semaphores,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture(autouse=True)
|
| 21 |
+
def _clean_semaphore_cache():
|
| 22 |
+
_reset_aux_semaphores()
|
| 23 |
+
yield
|
| 24 |
+
_reset_aux_semaphores()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TestGetTaskMaxConcurrency:
|
| 28 |
+
def test_returns_none_for_missing_task(self):
|
| 29 |
+
assert _get_task_max_concurrency(None) is None
|
| 30 |
+
assert _get_task_max_concurrency("") is None
|
| 31 |
+
|
| 32 |
+
def test_returns_none_when_unset(self):
|
| 33 |
+
with patch(
|
| 34 |
+
"agent.auxiliary_client._get_auxiliary_task_config", return_value={}
|
| 35 |
+
):
|
| 36 |
+
assert _get_task_max_concurrency("title_generation") is None
|
| 37 |
+
|
| 38 |
+
def test_does_not_reuse_vision_cpu_limit_for_llm_calls(self):
|
| 39 |
+
with patch(
|
| 40 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 41 |
+
return_value={"max_concurrency": 1},
|
| 42 |
+
):
|
| 43 |
+
assert _get_task_max_concurrency("vision") is None
|
| 44 |
+
|
| 45 |
+
def test_returns_int_when_configured(self):
|
| 46 |
+
with patch(
|
| 47 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 48 |
+
return_value={"max_concurrency": 3},
|
| 49 |
+
):
|
| 50 |
+
assert _get_task_max_concurrency("compression") == 3
|
| 51 |
+
|
| 52 |
+
def test_returns_none_for_non_numeric(self):
|
| 53 |
+
with patch(
|
| 54 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 55 |
+
return_value={"max_concurrency": "not-a-number"},
|
| 56 |
+
):
|
| 57 |
+
assert _get_task_max_concurrency("compression") is None
|
| 58 |
+
|
| 59 |
+
def test_returns_none_for_zero_or_negative(self):
|
| 60 |
+
with patch(
|
| 61 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 62 |
+
return_value={"max_concurrency": 0},
|
| 63 |
+
):
|
| 64 |
+
assert _get_task_max_concurrency("compression") is None
|
| 65 |
+
with patch(
|
| 66 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 67 |
+
return_value={"max_concurrency": -2},
|
| 68 |
+
):
|
| 69 |
+
assert _get_task_max_concurrency("compression") is None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TestSemaphoreCache:
|
| 73 |
+
def test_sync_returns_none_when_unset(self):
|
| 74 |
+
with patch(
|
| 75 |
+
"agent.auxiliary_client._get_auxiliary_task_config", return_value={}
|
| 76 |
+
):
|
| 77 |
+
assert _acquire_sync_aux_semaphore("title_generation") is None
|
| 78 |
+
|
| 79 |
+
def test_sync_reuses_semaphore_for_same_limit(self):
|
| 80 |
+
with patch(
|
| 81 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 82 |
+
return_value={"max_concurrency": 2},
|
| 83 |
+
):
|
| 84 |
+
sem1 = _acquire_sync_aux_semaphore("compression")
|
| 85 |
+
sem2 = _acquire_sync_aux_semaphore("compression")
|
| 86 |
+
assert sem1 is sem2
|
| 87 |
+
|
| 88 |
+
def test_sync_rebuilds_when_limit_changes(self):
|
| 89 |
+
cfg = {"max_concurrency": 2}
|
| 90 |
+
with patch(
|
| 91 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 92 |
+
return_value=cfg,
|
| 93 |
+
):
|
| 94 |
+
sem1 = _acquire_sync_aux_semaphore("compression")
|
| 95 |
+
cfg["max_concurrency"] = 5
|
| 96 |
+
sem2 = _acquire_sync_aux_semaphore("compression")
|
| 97 |
+
assert sem1 is not sem2
|
| 98 |
+
|
| 99 |
+
@pytest.mark.asyncio
|
| 100 |
+
async def test_async_reuses_semaphore_within_same_loop(self):
|
| 101 |
+
with patch(
|
| 102 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 103 |
+
return_value={"max_concurrency": 2},
|
| 104 |
+
):
|
| 105 |
+
sem1 = _acquire_async_aux_semaphore("compression")
|
| 106 |
+
sem2 = _acquire_async_aux_semaphore("compression")
|
| 107 |
+
assert sem1 is sem2
|
| 108 |
+
|
| 109 |
+
def test_async_returns_none_with_no_running_loop(self):
|
| 110 |
+
with patch(
|
| 111 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 112 |
+
return_value={"max_concurrency": 2},
|
| 113 |
+
):
|
| 114 |
+
# Called outside an asyncio loop — should bail rather than crash.
|
| 115 |
+
assert _acquire_async_aux_semaphore("compression") is None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class TestSyncCallEnforcesLimit:
|
| 119 |
+
def test_call_llm_caps_concurrent_inflight(self):
|
| 120 |
+
limit = 2
|
| 121 |
+
n_callers = 6
|
| 122 |
+
|
| 123 |
+
active = 0
|
| 124 |
+
max_active = 0
|
| 125 |
+
lock = threading.Lock()
|
| 126 |
+
|
| 127 |
+
def fake_create(**kwargs):
|
| 128 |
+
nonlocal active, max_active
|
| 129 |
+
with lock:
|
| 130 |
+
active += 1
|
| 131 |
+
if active > max_active:
|
| 132 |
+
max_active = active
|
| 133 |
+
try:
|
| 134 |
+
time.sleep(0.05)
|
| 135 |
+
finally:
|
| 136 |
+
with lock:
|
| 137 |
+
active -= 1
|
| 138 |
+
return MagicMock()
|
| 139 |
+
|
| 140 |
+
client = MagicMock()
|
| 141 |
+
client.base_url = "https://example.test/v1"
|
| 142 |
+
client.chat.completions.create.side_effect = fake_create
|
| 143 |
+
|
| 144 |
+
with (
|
| 145 |
+
patch(
|
| 146 |
+
"agent.auxiliary_client._resolve_task_provider_model",
|
| 147 |
+
return_value=("openrouter", "test-model", None, None, None),
|
| 148 |
+
),
|
| 149 |
+
patch(
|
| 150 |
+
"agent.auxiliary_client._get_cached_client",
|
| 151 |
+
return_value=(client, "test-model"),
|
| 152 |
+
),
|
| 153 |
+
patch(
|
| 154 |
+
"agent.auxiliary_client._validate_llm_response",
|
| 155 |
+
side_effect=lambda resp, _task, **_kwargs: resp,
|
| 156 |
+
),
|
| 157 |
+
patch(
|
| 158 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 159 |
+
return_value={"max_concurrency": limit},
|
| 160 |
+
),
|
| 161 |
+
):
|
| 162 |
+
threads = [
|
| 163 |
+
threading.Thread(
|
| 164 |
+
target=lambda: call_llm(
|
| 165 |
+
task="title_generation",
|
| 166 |
+
messages=[{"role": "user", "content": "hi"}],
|
| 167 |
+
)
|
| 168 |
+
)
|
| 169 |
+
for _ in range(n_callers)
|
| 170 |
+
]
|
| 171 |
+
for t in threads:
|
| 172 |
+
t.start()
|
| 173 |
+
for t in threads:
|
| 174 |
+
t.join(timeout=5)
|
| 175 |
+
|
| 176 |
+
assert max_active <= limit, f"observed {max_active} > limit {limit}"
|
| 177 |
+
assert client.chat.completions.create.call_count == n_callers
|
| 178 |
+
|
| 179 |
+
def test_call_llm_unlimited_when_not_configured(self):
|
| 180 |
+
client = MagicMock()
|
| 181 |
+
client.base_url = "https://example.test/v1"
|
| 182 |
+
client.chat.completions.create.return_value = MagicMock()
|
| 183 |
+
|
| 184 |
+
with (
|
| 185 |
+
patch(
|
| 186 |
+
"agent.auxiliary_client._resolve_task_provider_model",
|
| 187 |
+
return_value=("openrouter", "test-model", None, None, None),
|
| 188 |
+
),
|
| 189 |
+
patch(
|
| 190 |
+
"agent.auxiliary_client._get_cached_client",
|
| 191 |
+
return_value=(client, "test-model"),
|
| 192 |
+
),
|
| 193 |
+
patch(
|
| 194 |
+
"agent.auxiliary_client._validate_llm_response",
|
| 195 |
+
side_effect=lambda resp, _task, **_kwargs: resp,
|
| 196 |
+
),
|
| 197 |
+
patch(
|
| 198 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 199 |
+
return_value={},
|
| 200 |
+
),
|
| 201 |
+
):
|
| 202 |
+
# With no max_concurrency in config, no semaphore is acquired.
|
| 203 |
+
call_llm(
|
| 204 |
+
task="title_generation",
|
| 205 |
+
messages=[{"role": "user", "content": "hi"}],
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
assert client.chat.completions.create.call_count == 1
|
| 209 |
+
|
| 210 |
+
def test_semaphore_released_on_exception(self):
|
| 211 |
+
"""Errors inside call_llm must release the semaphore so the next call proceeds."""
|
| 212 |
+
client = MagicMock()
|
| 213 |
+
client.base_url = "https://example.test/v1"
|
| 214 |
+
client.chat.completions.create.side_effect = RuntimeError("boom")
|
| 215 |
+
|
| 216 |
+
with (
|
| 217 |
+
patch(
|
| 218 |
+
"agent.auxiliary_client._resolve_task_provider_model",
|
| 219 |
+
return_value=("openrouter", "test-model", None, None, None),
|
| 220 |
+
),
|
| 221 |
+
patch(
|
| 222 |
+
"agent.auxiliary_client._get_cached_client",
|
| 223 |
+
return_value=(client, "test-model"),
|
| 224 |
+
),
|
| 225 |
+
patch(
|
| 226 |
+
"agent.auxiliary_client._validate_llm_response",
|
| 227 |
+
side_effect=lambda resp, _task, **_kwargs: resp,
|
| 228 |
+
),
|
| 229 |
+
patch(
|
| 230 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 231 |
+
return_value={"max_concurrency": 1},
|
| 232 |
+
),
|
| 233 |
+
):
|
| 234 |
+
for _ in range(3):
|
| 235 |
+
with pytest.raises(RuntimeError, match="boom"):
|
| 236 |
+
call_llm(
|
| 237 |
+
task="title_generation",
|
| 238 |
+
messages=[{"role": "user", "content": "hi"}],
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
def test_stream_holds_permit_until_consumed_and_preserves_options(self):
|
| 242 |
+
client = MagicMock()
|
| 243 |
+
client.base_url = "https://example.test/v1"
|
| 244 |
+
client.chat.completions.create.side_effect = [iter(["chunk"]), MagicMock()]
|
| 245 |
+
second_call_started = threading.Event()
|
| 246 |
+
|
| 247 |
+
def make_second_call():
|
| 248 |
+
second_call_started.set()
|
| 249 |
+
call_llm(
|
| 250 |
+
task="compression",
|
| 251 |
+
messages=[{"role": "user", "content": "second"}],
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
with (
|
| 255 |
+
patch(
|
| 256 |
+
"agent.auxiliary_client._resolve_task_provider_model",
|
| 257 |
+
return_value=("openrouter", "test-model", None, None, None),
|
| 258 |
+
),
|
| 259 |
+
patch(
|
| 260 |
+
"agent.auxiliary_client._get_cached_client",
|
| 261 |
+
return_value=(client, "test-model"),
|
| 262 |
+
),
|
| 263 |
+
patch(
|
| 264 |
+
"agent.auxiliary_client._validate_llm_response",
|
| 265 |
+
side_effect=lambda response, _task, **_kwargs: response,
|
| 266 |
+
),
|
| 267 |
+
patch(
|
| 268 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 269 |
+
return_value={"max_concurrency": 1},
|
| 270 |
+
),
|
| 271 |
+
):
|
| 272 |
+
stream = call_llm(
|
| 273 |
+
task="compression",
|
| 274 |
+
messages=[{"role": "user", "content": "first"}],
|
| 275 |
+
stream=True,
|
| 276 |
+
stream_options={"include_usage": True},
|
| 277 |
+
)
|
| 278 |
+
thread = threading.Thread(target=make_second_call)
|
| 279 |
+
thread.start()
|
| 280 |
+
assert second_call_started.wait(timeout=1)
|
| 281 |
+
time.sleep(0.05)
|
| 282 |
+
assert client.chat.completions.create.call_count == 1
|
| 283 |
+
assert list(stream) == ["chunk"]
|
| 284 |
+
thread.join(timeout=1)
|
| 285 |
+
|
| 286 |
+
assert not thread.is_alive()
|
| 287 |
+
assert client.chat.completions.create.call_count == 2
|
| 288 |
+
assert client.chat.completions.create.call_args_list[0].kwargs["stream"] is True
|
| 289 |
+
assert client.chat.completions.create.call_args_list[0].kwargs["stream_options"] == {
|
| 290 |
+
"include_usage": True
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
def test_api_mode_is_forwarded_to_client_resolution(self):
|
| 294 |
+
client = MagicMock()
|
| 295 |
+
client.base_url = "https://example.test/v1"
|
| 296 |
+
client.chat.completions.create.return_value = MagicMock()
|
| 297 |
+
|
| 298 |
+
with (
|
| 299 |
+
patch(
|
| 300 |
+
"agent.auxiliary_client._resolve_task_provider_model",
|
| 301 |
+
return_value=("openrouter", "test-model", None, None, None),
|
| 302 |
+
),
|
| 303 |
+
patch(
|
| 304 |
+
"agent.auxiliary_client._get_cached_client",
|
| 305 |
+
return_value=(client, "test-model"),
|
| 306 |
+
) as get_client,
|
| 307 |
+
patch(
|
| 308 |
+
"agent.auxiliary_client._validate_llm_response",
|
| 309 |
+
side_effect=lambda response, _task, **_kwargs: response,
|
| 310 |
+
),
|
| 311 |
+
):
|
| 312 |
+
call_llm(
|
| 313 |
+
task="title_generation",
|
| 314 |
+
messages=[{"role": "user", "content": "hi"}],
|
| 315 |
+
api_mode="codex_responses",
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
assert get_client.call_args.kwargs["api_mode"] == "codex_responses"
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
class TestAsyncCallEnforcesLimit:
|
| 322 |
+
@pytest.mark.asyncio
|
| 323 |
+
async def test_async_call_llm_caps_concurrent_inflight(self):
|
| 324 |
+
limit = 2
|
| 325 |
+
n_callers = 6
|
| 326 |
+
|
| 327 |
+
active = 0
|
| 328 |
+
max_active = 0
|
| 329 |
+
|
| 330 |
+
async def fake_create(**kwargs):
|
| 331 |
+
nonlocal active, max_active
|
| 332 |
+
active += 1
|
| 333 |
+
if active > max_active:
|
| 334 |
+
max_active = active
|
| 335 |
+
try:
|
| 336 |
+
await asyncio.sleep(0.05)
|
| 337 |
+
finally:
|
| 338 |
+
active -= 1
|
| 339 |
+
return MagicMock()
|
| 340 |
+
|
| 341 |
+
client = MagicMock()
|
| 342 |
+
client.base_url = "https://example.test/v1"
|
| 343 |
+
client.chat.completions.create = AsyncMock(side_effect=fake_create)
|
| 344 |
+
|
| 345 |
+
with (
|
| 346 |
+
patch(
|
| 347 |
+
"agent.auxiliary_client._resolve_task_provider_model",
|
| 348 |
+
return_value=("openrouter", "test-model", None, None, None),
|
| 349 |
+
),
|
| 350 |
+
patch(
|
| 351 |
+
"agent.auxiliary_client._get_cached_client",
|
| 352 |
+
return_value=(client, "test-model"),
|
| 353 |
+
),
|
| 354 |
+
patch(
|
| 355 |
+
"agent.auxiliary_client._validate_llm_response",
|
| 356 |
+
side_effect=lambda resp, _task, **_kwargs: resp,
|
| 357 |
+
),
|
| 358 |
+
patch(
|
| 359 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 360 |
+
return_value={"max_concurrency": limit},
|
| 361 |
+
),
|
| 362 |
+
):
|
| 363 |
+
await asyncio.gather(*[
|
| 364 |
+
async_call_llm(
|
| 365 |
+
task="compression",
|
| 366 |
+
messages=[{"role": "user", "content": "hi"}],
|
| 367 |
+
)
|
| 368 |
+
for _ in range(n_callers)
|
| 369 |
+
])
|
| 370 |
+
|
| 371 |
+
assert max_active <= limit, f"observed {max_active} > limit {limit}"
|
| 372 |
+
assert client.chat.completions.create.await_count == n_callers
|
| 373 |
+
|
| 374 |
+
@pytest.mark.asyncio
|
| 375 |
+
async def test_async_semaphore_released_on_exception(self):
|
| 376 |
+
client = MagicMock()
|
| 377 |
+
client.base_url = "https://example.test/v1"
|
| 378 |
+
client.chat.completions.create = AsyncMock(side_effect=RuntimeError("boom"))
|
| 379 |
+
|
| 380 |
+
with (
|
| 381 |
+
patch(
|
| 382 |
+
"agent.auxiliary_client._resolve_task_provider_model",
|
| 383 |
+
return_value=("openrouter", "test-model", None, None, None),
|
| 384 |
+
),
|
| 385 |
+
patch(
|
| 386 |
+
"agent.auxiliary_client._get_cached_client",
|
| 387 |
+
return_value=(client, "test-model"),
|
| 388 |
+
),
|
| 389 |
+
patch(
|
| 390 |
+
"agent.auxiliary_client._validate_llm_response",
|
| 391 |
+
side_effect=lambda resp, _task, **_kwargs: resp,
|
| 392 |
+
),
|
| 393 |
+
patch(
|
| 394 |
+
"agent.auxiliary_client._get_auxiliary_task_config",
|
| 395 |
+
return_value={"max_concurrency": 1},
|
| 396 |
+
),
|
| 397 |
+
):
|
| 398 |
+
for _ in range(3):
|
| 399 |
+
with pytest.raises(RuntimeError, match="boom"):
|
| 400 |
+
await async_call_llm(
|
| 401 |
+
task="compression",
|
| 402 |
+
messages=[{"role": "user", "content": "hi"}],
|
| 403 |
+
)
|
tests/agent/test_auxiliary_named_custom_providers.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for named custom provider and 'main' alias resolution in auxiliary_client."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from unittest.mock import patch, MagicMock
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture(autouse=True)
|
| 10 |
+
def _isolate(tmp_path, monkeypatch):
|
| 11 |
+
"""Redirect HERMES_HOME and clear module caches."""
|
| 12 |
+
hermes_home = tmp_path / ".hermes"
|
| 13 |
+
hermes_home.mkdir()
|
| 14 |
+
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
| 15 |
+
# Write a minimal config so load_config doesn't fail
|
| 16 |
+
(hermes_home / "config.yaml").write_text("model:\n default: test-model\n")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _write_config(tmp_path, config_dict):
|
| 20 |
+
"""Write a config.yaml to the test HERMES_HOME."""
|
| 21 |
+
import yaml
|
| 22 |
+
config_path = tmp_path / ".hermes" / "config.yaml"
|
| 23 |
+
config_path.write_text(yaml.dump(config_dict))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TestNormalizeVisionProvider:
|
| 27 |
+
"""_normalize_vision_provider should resolve 'main' to actual main provider."""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_main_resolves_to_openrouter(self, tmp_path):
|
| 31 |
+
_write_config(tmp_path, {
|
| 32 |
+
"model": {"default": "anthropic/claude-sonnet-4", "provider": "openrouter"},
|
| 33 |
+
})
|
| 34 |
+
from agent.auxiliary_client import _normalize_vision_provider
|
| 35 |
+
assert _normalize_vision_provider("main") == "openrouter"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_auto_unchanged(self):
|
| 43 |
+
from agent.auxiliary_client import _normalize_vision_provider
|
| 44 |
+
assert _normalize_vision_provider("auto") == "auto"
|
| 45 |
+
assert _normalize_vision_provider(None) == "auto"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class TestResolveProviderClientMainAlias:
|
| 49 |
+
"""resolve_provider_client('main', ...) should resolve to actual main provider."""
|
| 50 |
+
|
| 51 |
+
def test_main_resolves_to_named_custom_provider(self, tmp_path):
|
| 52 |
+
_write_config(tmp_path, {
|
| 53 |
+
"model": {"default": "my-model", "provider": "beans"},
|
| 54 |
+
"custom_providers": [
|
| 55 |
+
{"name": "beans", "base_url": "http://beans.local/v1", "api_key": "k"},
|
| 56 |
+
],
|
| 57 |
+
})
|
| 58 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 59 |
+
client, model = resolve_provider_client("main", "override-model")
|
| 60 |
+
assert client is not None
|
| 61 |
+
assert model == "override-model"
|
| 62 |
+
assert "beans.local" in str(client.base_url)
|
| 63 |
+
|
| 64 |
+
def test_main_with_custom_colon_prefix(self, tmp_path):
|
| 65 |
+
_write_config(tmp_path, {
|
| 66 |
+
"model": {"default": "my-model", "provider": "custom:beans"},
|
| 67 |
+
"custom_providers": [
|
| 68 |
+
{"name": "beans", "base_url": "http://beans.local/v1", "api_key": "k"},
|
| 69 |
+
],
|
| 70 |
+
})
|
| 71 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 72 |
+
client, model = resolve_provider_client("main", "test")
|
| 73 |
+
assert client is not None
|
| 74 |
+
assert "beans.local" in str(client.base_url)
|
| 75 |
+
|
| 76 |
+
def test_main_resolves_github_copilot_alias(self, tmp_path):
|
| 77 |
+
_write_config(tmp_path, {
|
| 78 |
+
"model": {"default": "gpt-5.4", "provider": "github-copilot"},
|
| 79 |
+
})
|
| 80 |
+
with (
|
| 81 |
+
patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
|
| 82 |
+
"api_key": "ghu_test_token",
|
| 83 |
+
"base_url": "https://api.githubcopilot.com",
|
| 84 |
+
}),
|
| 85 |
+
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
| 86 |
+
):
|
| 87 |
+
mock_openai.return_value = MagicMock()
|
| 88 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 89 |
+
|
| 90 |
+
client, model = resolve_provider_client("main", "gpt-5.4")
|
| 91 |
+
|
| 92 |
+
assert client is not None
|
| 93 |
+
assert model == "gpt-5.4"
|
| 94 |
+
assert mock_openai.called
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class TestResolveProviderClientNamedCustom:
|
| 98 |
+
"""resolve_provider_client should resolve named custom providers directly."""
|
| 99 |
+
|
| 100 |
+
def test_named_custom_provider(self, tmp_path):
|
| 101 |
+
_write_config(tmp_path, {
|
| 102 |
+
"model": {"default": "test-model"},
|
| 103 |
+
"custom_providers": [
|
| 104 |
+
{"name": "beans", "base_url": "http://beans.local/v1", "api_key": "k"},
|
| 105 |
+
],
|
| 106 |
+
})
|
| 107 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 108 |
+
client, model = resolve_provider_client("beans", "my-model")
|
| 109 |
+
assert client is not None
|
| 110 |
+
assert model == "my-model"
|
| 111 |
+
assert "beans.local" in str(client.base_url)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_named_custom_no_api_key_uses_fallback(self, tmp_path):
|
| 115 |
+
_write_config(tmp_path, {
|
| 116 |
+
"model": {"default": "test"},
|
| 117 |
+
"custom_providers": [
|
| 118 |
+
{"name": "local", "base_url": "http://localhost:8080/v1"},
|
| 119 |
+
],
|
| 120 |
+
})
|
| 121 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 122 |
+
client, model = resolve_provider_client("local", "test")
|
| 123 |
+
assert client is not None
|
| 124 |
+
# no-key-required should be used
|
| 125 |
+
|
| 126 |
+
def test_providers_dict_uses_durable_pool_when_no_inline_key(self, tmp_path):
|
| 127 |
+
"""Titles/compression/vision must read credential_pool.<key>, not a placeholder."""
|
| 128 |
+
_write_config(tmp_path, {
|
| 129 |
+
"providers": {
|
| 130 |
+
"b-ai": {
|
| 131 |
+
"name": "B.AI",
|
| 132 |
+
"base_url": "https://api.b.ai/v1",
|
| 133 |
+
},
|
| 134 |
+
},
|
| 135 |
+
})
|
| 136 |
+
auth_path = tmp_path / ".hermes" / "auth.json"
|
| 137 |
+
auth_path.write_text(json.dumps({
|
| 138 |
+
"version": 1,
|
| 139 |
+
"providers": {},
|
| 140 |
+
"credential_pool": {
|
| 141 |
+
"b-ai": [
|
| 142 |
+
{
|
| 143 |
+
"id": "k1",
|
| 144 |
+
"label": "primary",
|
| 145 |
+
"auth_type": "api_key",
|
| 146 |
+
"priority": 0,
|
| 147 |
+
"source": "manual",
|
| 148 |
+
"access_token": "sk-real-b-ai-pool-key-12345",
|
| 149 |
+
}
|
| 150 |
+
]
|
| 151 |
+
},
|
| 152 |
+
}))
|
| 153 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 154 |
+
client, _model = resolve_provider_client("b-ai", "b-ai-model")
|
| 155 |
+
assert client is not None
|
| 156 |
+
assert "api.b.ai" in str(client.base_url)
|
| 157 |
+
assert client.api_key == "sk-real-b-ai-pool-key-12345"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class TestResolveProviderClientModelNormalization:
|
| 161 |
+
"""Direct-provider auxiliary routing should normalize models like main runtime."""
|
| 162 |
+
|
| 163 |
+
def test_matching_native_prefix_is_stripped_for_main_provider(self, tmp_path):
|
| 164 |
+
_write_config(tmp_path, {
|
| 165 |
+
"model": {"default": "zai/glm-5.1", "provider": "zai"},
|
| 166 |
+
})
|
| 167 |
+
with (
|
| 168 |
+
patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
|
| 169 |
+
"api_key": "glm-key",
|
| 170 |
+
"base_url": "https://api.z.ai/api/paas/v4",
|
| 171 |
+
}),
|
| 172 |
+
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
| 173 |
+
):
|
| 174 |
+
mock_openai.return_value = MagicMock()
|
| 175 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 176 |
+
|
| 177 |
+
client, model = resolve_provider_client("main", "zai/glm-5.1")
|
| 178 |
+
|
| 179 |
+
assert client is not None
|
| 180 |
+
assert model == "glm-5.1"
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_aggregator_vendor_slug_is_preserved(self, monkeypatch):
|
| 184 |
+
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
| 185 |
+
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
| 186 |
+
mock_openai.return_value = MagicMock()
|
| 187 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 188 |
+
|
| 189 |
+
client, model = resolve_provider_client(
|
| 190 |
+
"openrouter", "anthropic/claude-sonnet-4.6"
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
assert client is not None
|
| 194 |
+
assert model == "anthropic/claude-sonnet-4.6"
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
class TestResolveVisionProviderClientModelNormalization:
|
| 198 |
+
"""Vision auto-routing should reuse the same provider-specific normalization."""
|
| 199 |
+
|
| 200 |
+
def test_vision_auto_strips_matching_main_provider_prefix(self, tmp_path):
|
| 201 |
+
_write_config(tmp_path, {
|
| 202 |
+
"model": {"default": "zai/glm-5.1", "provider": "zai"},
|
| 203 |
+
})
|
| 204 |
+
with (
|
| 205 |
+
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
| 206 |
+
patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
|
| 207 |
+
"api_key": "glm-key",
|
| 208 |
+
"base_url": "https://api.z.ai/api/paas/v4",
|
| 209 |
+
}),
|
| 210 |
+
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
| 211 |
+
):
|
| 212 |
+
mock_openai.return_value = MagicMock()
|
| 213 |
+
from agent.auxiliary_client import resolve_vision_provider_client
|
| 214 |
+
|
| 215 |
+
provider, client, model = resolve_vision_provider_client()
|
| 216 |
+
|
| 217 |
+
assert provider == "zai"
|
| 218 |
+
assert client is not None
|
| 219 |
+
assert model == "glm-5v-turbo" # zai has dedicated vision model in _PROVIDER_VISION_MODELS
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class TestAutoClientCacheModelCompatibility:
|
| 223 |
+
"""Auto client cache should not keep OpenRouter-format model overrides on non-OR clients."""
|
| 224 |
+
|
| 225 |
+
def test_first_auto_cache_miss_drops_openrouter_model_for_named_custom_runtime(self, tmp_path):
|
| 226 |
+
from agent import auxiliary_client as ac
|
| 227 |
+
|
| 228 |
+
ac._client_cache.clear()
|
| 229 |
+
try:
|
| 230 |
+
fake_client = MagicMock()
|
| 231 |
+
fake_client.base_url = "https://aixj.vip/v1"
|
| 232 |
+
fake_client.api_key = "test-key"
|
| 233 |
+
|
| 234 |
+
runtime = {
|
| 235 |
+
"provider": "custom:aixj.vip",
|
| 236 |
+
"model": "gpt-5.4",
|
| 237 |
+
"base_url": "https://aixj.vip/v1",
|
| 238 |
+
"api_key": "***",
|
| 239 |
+
"api_mode": "codex_responses",
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
with patch(
|
| 243 |
+
"agent.auxiliary_client.resolve_provider_client",
|
| 244 |
+
return_value=(fake_client, "gpt-5.4"),
|
| 245 |
+
) as mock_resolve:
|
| 246 |
+
client, model = ac._get_cached_client(
|
| 247 |
+
"auto",
|
| 248 |
+
"google/gemini-3-flash-preview",
|
| 249 |
+
main_runtime=runtime,
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
assert client is fake_client
|
| 253 |
+
assert model == "gpt-5.4"
|
| 254 |
+
mock_resolve.assert_called_once()
|
| 255 |
+
finally:
|
| 256 |
+
ac._client_cache.clear()
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
class TestVisionPathApiMode:
|
| 260 |
+
"""Vision path should propagate api_mode to _get_cached_client."""
|
| 261 |
+
|
| 262 |
+
def test_explicit_provider_passes_api_mode(self, tmp_path):
|
| 263 |
+
_write_config(tmp_path, {
|
| 264 |
+
"model": {"default": "test-model"},
|
| 265 |
+
"auxiliary": {"vision": {"api_mode": "chat_completions"}},
|
| 266 |
+
})
|
| 267 |
+
with patch("agent.auxiliary_client._get_cached_client") as mock_gcc:
|
| 268 |
+
mock_gcc.return_value = (MagicMock(), "test-model")
|
| 269 |
+
from agent.auxiliary_client import resolve_vision_provider_client
|
| 270 |
+
|
| 271 |
+
provider, client, model = resolve_vision_provider_client(provider="deepseek")
|
| 272 |
+
|
| 273 |
+
mock_gcc.assert_called_once()
|
| 274 |
+
_, kwargs = mock_gcc.call_args
|
| 275 |
+
assert kwargs.get("api_mode") == "chat_completions"
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
class TestProvidersDictApiModeAnthropicMessages:
|
| 279 |
+
"""Regression guard for #15033.
|
| 280 |
+
|
| 281 |
+
Named providers declared under the ``providers:`` dict with
|
| 282 |
+
``api_mode: anthropic_messages`` must route auxiliary calls through
|
| 283 |
+
the Anthropic Messages API (via AnthropicAuxiliaryClient), not
|
| 284 |
+
through an OpenAI chat-completions client.
|
| 285 |
+
|
| 286 |
+
The bug had two halves: the providers-dict branch of
|
| 287 |
+
``_get_named_custom_provider`` dropped the ``api_mode`` field, and
|
| 288 |
+
``resolve_provider_client``'s named-custom branch never read it.
|
| 289 |
+
"""
|
| 290 |
+
|
| 291 |
+
def test_providers_dict_propagates_api_mode(self, tmp_path, monkeypatch):
|
| 292 |
+
monkeypatch.setenv("MYRELAY_API_KEY", "sk-test")
|
| 293 |
+
_write_config(tmp_path, {
|
| 294 |
+
"providers": {
|
| 295 |
+
"myrelay": {
|
| 296 |
+
"name": "myrelay",
|
| 297 |
+
"base_url": "https://example-relay.test/anthropic",
|
| 298 |
+
"key_env": "MYRELAY_API_KEY",
|
| 299 |
+
"api_mode": "anthropic_messages",
|
| 300 |
+
"default_model": "claude-opus-4-7",
|
| 301 |
+
},
|
| 302 |
+
},
|
| 303 |
+
})
|
| 304 |
+
from hermes_cli.runtime_provider import _get_named_custom_provider
|
| 305 |
+
entry = _get_named_custom_provider("myrelay")
|
| 306 |
+
assert entry is not None
|
| 307 |
+
assert entry.get("api_mode") == "anthropic_messages"
|
| 308 |
+
assert entry.get("base_url") == "https://example-relay.test/anthropic"
|
| 309 |
+
assert entry.get("api_key") == "sk-test"
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def test_resolve_provider_client_returns_anthropic_client(self, tmp_path, monkeypatch):
|
| 314 |
+
"""Named custom provider with api_mode=anthropic_messages must
|
| 315 |
+
route through AnthropicAuxiliaryClient."""
|
| 316 |
+
monkeypatch.setenv("MYRELAY_API_KEY", "sk-test")
|
| 317 |
+
_write_config(tmp_path, {
|
| 318 |
+
"providers": {
|
| 319 |
+
"myrelay": {
|
| 320 |
+
"name": "myrelay",
|
| 321 |
+
"base_url": "https://example-relay.test/anthropic",
|
| 322 |
+
"key_env": "MYRELAY_API_KEY",
|
| 323 |
+
"api_mode": "anthropic_messages",
|
| 324 |
+
"default_model": "claude-opus-4-7",
|
| 325 |
+
},
|
| 326 |
+
},
|
| 327 |
+
})
|
| 328 |
+
from agent.auxiliary_client import (
|
| 329 |
+
resolve_provider_client,
|
| 330 |
+
AnthropicAuxiliaryClient,
|
| 331 |
+
AsyncAnthropicAuxiliaryClient,
|
| 332 |
+
)
|
| 333 |
+
sync_client, sync_model = resolve_provider_client("myrelay", async_mode=False)
|
| 334 |
+
assert isinstance(sync_client, AnthropicAuxiliaryClient), (
|
| 335 |
+
f"expected AnthropicAuxiliaryClient, got {type(sync_client).__name__}"
|
| 336 |
+
)
|
| 337 |
+
assert sync_model == "claude-opus-4-7"
|
| 338 |
+
|
| 339 |
+
async_client, async_model = resolve_provider_client("myrelay", async_mode=True)
|
| 340 |
+
assert isinstance(async_client, AsyncAnthropicAuxiliaryClient), (
|
| 341 |
+
f"expected AsyncAnthropicAuxiliaryClient, got {type(async_client).__name__}"
|
| 342 |
+
)
|
| 343 |
+
assert async_model == "claude-opus-4-7"
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
class TestCustomProviderAliasCollision:
|
| 349 |
+
"""A user-declared custom_providers entry whose name matches a built-in
|
| 350 |
+
*alias* (not a canonical provider) must win over the built-in.
|
| 351 |
+
|
| 352 |
+
Regression guard for #15743: users who defined fallback_model pointing at
|
| 353 |
+
a custom_providers entry named ``kimi`` were having requests routed to
|
| 354 |
+
the built-in kimi-coding endpoint because ``_normalize_aux_provider``
|
| 355 |
+
rewrote ``kimi`` → ``kimi-coding`` before the named-custom lookup.
|
| 356 |
+
"""
|
| 357 |
+
|
| 358 |
+
def test_custom_named_kimi_wins_over_builtin_alias(self, tmp_path):
|
| 359 |
+
_write_config(tmp_path, {
|
| 360 |
+
"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"},
|
| 361 |
+
"custom_providers": [
|
| 362 |
+
{
|
| 363 |
+
"name": "kimi",
|
| 364 |
+
"base_url": "https://my-custom-kimi.example.com/v1",
|
| 365 |
+
"api_key": "my-kimi-key",
|
| 366 |
+
"models": {"my-kimi-model": {"context_length": 200000}},
|
| 367 |
+
},
|
| 368 |
+
],
|
| 369 |
+
})
|
| 370 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 371 |
+
from openai import OpenAI
|
| 372 |
+
client, model = resolve_provider_client("kimi", model="my-kimi-model", raw_codex=True)
|
| 373 |
+
assert isinstance(client, OpenAI)
|
| 374 |
+
assert "my-custom-kimi.example.com" in str(client.base_url)
|
| 375 |
+
assert client.api_key == "my-kimi-key"
|
| 376 |
+
assert model == "my-kimi-model"
|
| 377 |
+
|
| 378 |
+
def test_bare_kimi_without_custom_still_routes_to_builtin(self, tmp_path, monkeypatch):
|
| 379 |
+
"""Regression guard: bare 'kimi' with no custom entry must still
|
| 380 |
+
reach the built-in kimi-coding provider."""
|
| 381 |
+
_write_config(tmp_path, {
|
| 382 |
+
"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"},
|
| 383 |
+
})
|
| 384 |
+
monkeypatch.setenv("KIMI_API_KEY", "builtin-kimi-key")
|
| 385 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 386 |
+
client, _ = resolve_provider_client("kimi", model="kimi-k2-0905-preview", raw_codex=True)
|
| 387 |
+
assert client is not None
|
| 388 |
+
base_url = str(client.base_url)
|
| 389 |
+
# Built-in kimi-coding points at api.moonshot.ai
|
| 390 |
+
assert "moonshot" in base_url or "kimi" in base_url, f"unexpected base_url {base_url!r}"
|
| 391 |
+
|
| 392 |
+
def test_explicit_overrides_applied_on_api_key_branch(self, tmp_path, monkeypatch):
|
| 393 |
+
"""Explicit base_url/api_key from the caller must override the
|
| 394 |
+
registered provider's defaults on the API-key branch. Used by
|
| 395 |
+
_try_activate_fallback to route a fallback through a built-in
|
| 396 |
+
provider name but targeting a user-supplied endpoint."""
|
| 397 |
+
_write_config(tmp_path, {
|
| 398 |
+
"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"},
|
| 399 |
+
})
|
| 400 |
+
monkeypatch.setenv("KIMI_API_KEY", "builtin-kimi-key")
|
| 401 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 402 |
+
from openai import OpenAI
|
| 403 |
+
client, _ = resolve_provider_client(
|
| 404 |
+
"kimi-coding", model="kimi-k2", raw_codex=True,
|
| 405 |
+
explicit_base_url="https://override.example.com",
|
| 406 |
+
explicit_api_key="override-key",
|
| 407 |
+
)
|
| 408 |
+
assert isinstance(client, OpenAI)
|
| 409 |
+
assert "override.example.com" in str(client.base_url)
|
| 410 |
+
assert client.api_key == "override-key"
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
class TestResolveProviderClientMainRuntimeCustom:
|
| 414 |
+
"""When the main agent uses a named custom provider (custom:<name>),
|
| 415 |
+
resolve_provider_client('custom', ..., main_runtime=...) must reuse the
|
| 416 |
+
main_runtime's base_url + api_key instead of re-resolving from the bare
|
| 417 |
+
'custom' provider name. Re-resolution loses the provider name and falls
|
| 418 |
+
back to OpenRouter or a wrong API-key provider. (#45472)"""
|
| 419 |
+
|
| 420 |
+
def test_custom_provider_main_runtime_used_directly(self, tmp_path, monkeypatch):
|
| 421 |
+
"""main_runtime with base_url + api_key for a named custom provider
|
| 422 |
+
is used directly, bypassing the _try_custom_endpoint / API-key
|
| 423 |
+
fallback chain."""
|
| 424 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 425 |
+
main_runtime = {
|
| 426 |
+
"provider": "custom",
|
| 427 |
+
"base_url": "https://my-gateway.example.com/v1",
|
| 428 |
+
"api_key": "***",
|
| 429 |
+
"model": "glm-5.1",
|
| 430 |
+
}
|
| 431 |
+
client, model = resolve_provider_client(
|
| 432 |
+
"custom",
|
| 433 |
+
model="explicit-glm-5.1",
|
| 434 |
+
main_runtime=main_runtime,
|
| 435 |
+
)
|
| 436 |
+
assert client is not None
|
| 437 |
+
assert model == "explicit-glm-5.1"
|
| 438 |
+
assert "my-gateway.example.com" in str(client.base_url)
|
| 439 |
+
assert client.api_key == "***"
|
| 440 |
+
|
| 441 |
+
def test_custom_provider_main_runtime_no_credentials_falls_through(self, tmp_path, monkeypatch):
|
| 442 |
+
"""When main_runtime has no base_url or no api_key, the existing
|
| 443 |
+
_try_custom_endpoint / _resolve_api_key_provider fallback chain is
|
| 444 |
+
still tried."""
|
| 445 |
+
# Ensure no env-provided credentials interfere
|
| 446 |
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
| 447 |
+
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
| 448 |
+
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 449 |
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
| 450 |
+
|
| 451 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 452 |
+
# main_runtime with key but no base_url → must fall through
|
| 453 |
+
client, model = resolve_provider_client(
|
| 454 |
+
"custom",
|
| 455 |
+
main_runtime={"api_key": "k", "base_url": ""},
|
| 456 |
+
)
|
| 457 |
+
# Should fall through to _try_custom_endpoint → return None,None
|
| 458 |
+
# because no OPENAI_BASE_URL is set and no custom endpoint is configured
|
| 459 |
+
assert client is None
|
| 460 |
+
|
| 461 |
+
def test_custom_provider_main_runtime_respects_explicit_base_url(self, tmp_path):
|
| 462 |
+
"""explicit_base_url still wins over main_runtime — the caller's
|
| 463 |
+
explicit argument is the strongest signal."""
|
| 464 |
+
from agent.auxiliary_client import resolve_provider_client
|
| 465 |
+
main_runtime = {
|
| 466 |
+
"base_url": "https://main-runtime.example.com/v1",
|
| 467 |
+
"api_key": "sk-main",
|
| 468 |
+
"model": "ignored-model",
|
| 469 |
+
}
|
| 470 |
+
client, model = resolve_provider_client(
|
| 471 |
+
"custom",
|
| 472 |
+
model="explicit-model",
|
| 473 |
+
explicit_base_url="https://explicit.example.com/v1",
|
| 474 |
+
explicit_api_key="sk-explicit",
|
| 475 |
+
main_runtime=main_runtime,
|
| 476 |
+
)
|
| 477 |
+
assert client is not None
|
| 478 |
+
assert model == "explicit-model"
|
| 479 |
+
assert "explicit.example.com" in str(client.base_url)
|
| 480 |
+
assert client.api_key == "sk-explicit"
|
tests/agent/test_auxiliary_transient_retry.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transient-transport retry count + per-model client-cache isolation.
|
| 2 |
+
|
| 3 |
+
Two related hardening behaviors for auxiliary calls (which include MoA
|
| 4 |
+
reference advisors, a pinned-model path where provider fallback is not a
|
| 5 |
+
meaningful recovery):
|
| 6 |
+
|
| 7 |
+
1. A transient transport blip (connection reset / timeout / 5xx) is retried
|
| 8 |
+
on the SAME provider several times with backoff before giving up — a single
|
| 9 |
+
upstream blip should not silently lose a pinned auxiliary call (root of the
|
| 10 |
+
run2 double-advisor "Connection error" collapse).
|
| 11 |
+
2. Two auxiliary calls to the same provider/base_url/key but DIFFERENT models
|
| 12 |
+
get DISTINCT client-cache keys, so a concurrent fan-out (e.g. opus + gpt-5.5
|
| 13 |
+
advisors) never shares one client entry.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import types
|
| 20 |
+
from unittest.mock import patch
|
| 21 |
+
|
| 22 |
+
import pytest
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_transient_retry_count_default(monkeypatch):
|
| 28 |
+
from agent import auxiliary_client as ac
|
| 29 |
+
|
| 30 |
+
# No config value -> default.
|
| 31 |
+
monkeypatch.setattr(ac, "load_config", lambda: {}, raising=False)
|
| 32 |
+
with patch("hermes_cli.config.load_config", return_value={}), \
|
| 33 |
+
patch("hermes_cli.config.cfg_get", return_value=None):
|
| 34 |
+
assert ac._transient_retry_count() == ac._DEFAULT_TRANSIENT_RETRIES
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_model_participates_in_client_cache_key():
|
| 40 |
+
"""Same provider/base_url/key, different model -> different cache key.
|
| 41 |
+
|
| 42 |
+
This is what stops two concurrent advisors from sharing (and racing on)
|
| 43 |
+
one cached client entry."""
|
| 44 |
+
from agent.auxiliary_client import _client_cache_key
|
| 45 |
+
|
| 46 |
+
k_opus = _client_cache_key(
|
| 47 |
+
"openrouter", async_mode=False, base_url="https://openrouter.ai/api/v1",
|
| 48 |
+
api_key="K", model="anthropic/claude-opus-4.8",
|
| 49 |
+
)
|
| 50 |
+
k_gpt = _client_cache_key(
|
| 51 |
+
"openrouter", async_mode=False, base_url="https://openrouter.ai/api/v1",
|
| 52 |
+
api_key="K", model="openai/gpt-5.5",
|
| 53 |
+
)
|
| 54 |
+
assert k_opus != k_gpt
|
| 55 |
+
# Same model still collides (cache still works for reuse).
|
| 56 |
+
k_opus2 = _client_cache_key(
|
| 57 |
+
"openrouter", async_mode=False, base_url="https://openrouter.ai/api/v1",
|
| 58 |
+
api_key="K", model="anthropic/claude-opus-4.8",
|
| 59 |
+
)
|
| 60 |
+
assert k_opus == k_opus2
|
| 61 |
+
|
| 62 |
+
|
tests/agent/test_azure_identity_adapter.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Microsoft Entra ID adapter (agent/azure_identity_adapter.py).
|
| 2 |
+
|
| 3 |
+
Covers:
|
| 4 |
+
- Scope resolution per Azure host shape
|
| 5 |
+
- Display masking for callable + string + None inputs
|
| 6 |
+
- Cache-fingerprint stability under callable refresh
|
| 7 |
+
- is_token_provider truthiness on callables vs strings
|
| 8 |
+
- EntraIdentityConfig serialization round-trip
|
| 9 |
+
- Token provider construction with mocked azure-identity
|
| 10 |
+
- Credential cache reuse + reset
|
| 11 |
+
- has_azure_identity_credentials timeout / failure paths
|
| 12 |
+
- describe_active_credential structural reporting
|
| 13 |
+
- Lazy-install error path when azure-identity absent + lazy installs
|
| 14 |
+
disabled
|
| 15 |
+
|
| 16 |
+
We mock azure.identity at the import boundary rather than hitting any
|
| 17 |
+
real Azure endpoint. Tests must remain hermetic per AGENTS.md.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import sys
|
| 23 |
+
from collections.abc import Callable
|
| 24 |
+
from types import SimpleNamespace
|
| 25 |
+
from typing import cast
|
| 26 |
+
|
| 27 |
+
import pytest
|
| 28 |
+
|
| 29 |
+
# Ensure we always import a fresh adapter module — credential caches in
|
| 30 |
+
# the adapter persist across tests otherwise, polluting assertions
|
| 31 |
+
# about cache invalidation.
|
| 32 |
+
@pytest.fixture(autouse=True)
|
| 33 |
+
def _reset_adapter_cache():
|
| 34 |
+
from agent.azure_identity_adapter import reset_credential_cache
|
| 35 |
+
reset_credential_cache()
|
| 36 |
+
yield
|
| 37 |
+
reset_credential_cache()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# Scope constant
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class TestEntraScopeConstant:
|
| 46 |
+
"""Pin the Microsoft-documented Foundry inference scope.
|
| 47 |
+
|
| 48 |
+
Microsoft's official samples for both ``*.openai.azure.com`` and
|
| 49 |
+
``*.services.ai.azure.com`` use ``https://ai.azure.com/.default``.
|
| 50 |
+
The older ``cognitiveservices.azure.com/.default`` is the
|
| 51 |
+
control-plane scope and is rejected for inference by newer
|
| 52 |
+
Azure OpenAI / Foundry resources.
|
| 53 |
+
|
| 54 |
+
Users with sovereign-cloud or unusual-tenant requirements pass the
|
| 55 |
+
scope explicitly via ``model.entra.scope`` in ``config.yaml``.
|
| 56 |
+
|
| 57 |
+
Refs:
|
| 58 |
+
* https://learn.microsoft.com/azure/ai-foundry/openai/how-to/managed-identity
|
| 59 |
+
* https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
def test_default_scope_matches_microsoft_documentation(self):
|
| 63 |
+
from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT
|
| 64 |
+
assert SCOPE_AI_AZURE_DEFAULT == "https://ai.azure.com/.default"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
# Cache fingerprint + http-bearer helpers
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TestMaterializeBearerForHttp:
|
| 73 |
+
"""The only helper that mints a real bearer JWT — must call the
|
| 74 |
+
callable exactly once and never fall through to display masking."""
|
| 75 |
+
|
| 76 |
+
def test_callable_is_invoked_and_returns_token(self):
|
| 77 |
+
from agent.azure_identity_adapter import materialize_bearer_for_http
|
| 78 |
+
|
| 79 |
+
invoked = {"count": 0}
|
| 80 |
+
|
| 81 |
+
def provider():
|
| 82 |
+
invoked["count"] += 1
|
| 83 |
+
return "fresh-jwt"
|
| 84 |
+
|
| 85 |
+
assert materialize_bearer_for_http(provider) == "fresh-jwt"
|
| 86 |
+
assert invoked["count"] == 1
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_empty_string_raises(self):
|
| 91 |
+
from agent.azure_identity_adapter import materialize_bearer_for_http
|
| 92 |
+
with pytest.raises(ValueError):
|
| 93 |
+
materialize_bearer_for_http("")
|
| 94 |
+
with pytest.raises(ValueError):
|
| 95 |
+
materialize_bearer_for_http(None)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ---------------------------------------------------------------------------
|
| 99 |
+
# build_bearer_http_client — the Anthropic-on-Foundry bridge
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class TestBuildBearerHttpClient:
|
| 104 |
+
"""``build_bearer_http_client`` returns an ``httpx.Client`` whose
|
| 105 |
+
request event hook mints a fresh JWT per outbound request. This is
|
| 106 |
+
how Entra ID auth reaches the Anthropic SDK (which does not accept
|
| 107 |
+
callable ``auth_token``)."""
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_hook_overrides_authorization_header(self):
|
| 111 |
+
import httpx
|
| 112 |
+
from agent.azure_identity_adapter import build_bearer_http_client
|
| 113 |
+
|
| 114 |
+
minted_tokens = []
|
| 115 |
+
|
| 116 |
+
def provider():
|
| 117 |
+
minted_tokens.append(f"jwt-{len(minted_tokens) + 1}")
|
| 118 |
+
return minted_tokens[-1]
|
| 119 |
+
|
| 120 |
+
client = build_bearer_http_client(provider)
|
| 121 |
+
try:
|
| 122 |
+
hook = client.event_hooks["request"][0]
|
| 123 |
+
# Build a request with conflicting pre-set headers and verify
|
| 124 |
+
# the hook strips them and installs the fresh bearer.
|
| 125 |
+
req = httpx.Request(
|
| 126 |
+
"POST", "https://example.com/v1/messages",
|
| 127 |
+
headers={
|
| 128 |
+
"Authorization": "Bearer stale-token",
|
| 129 |
+
"api-key": "static-key",
|
| 130 |
+
"x-api-key": "static-key",
|
| 131 |
+
},
|
| 132 |
+
json={"hello": "world"},
|
| 133 |
+
)
|
| 134 |
+
hook(req)
|
| 135 |
+
assert req.headers["Authorization"] == "Bearer jwt-1"
|
| 136 |
+
# The static-key headers must be stripped — sending both
|
| 137 |
+
# auth values would be ambiguous on Azure.
|
| 138 |
+
assert "api-key" not in req.headers
|
| 139 |
+
assert "x-api-key" not in req.headers
|
| 140 |
+
|
| 141 |
+
# Second invocation mints a fresh token.
|
| 142 |
+
req2 = httpx.Request("GET", "https://example.com/v1/models")
|
| 143 |
+
hook(req2)
|
| 144 |
+
assert req2.headers["Authorization"] == "Bearer jwt-2"
|
| 145 |
+
assert len(minted_tokens) == 2
|
| 146 |
+
finally:
|
| 147 |
+
client.close()
|
| 148 |
+
|
| 149 |
+
def test_hook_strips_auth_headers_and_warns_when_token_provider_fails(self, caplog):
|
| 150 |
+
"""When the token provider fails (chain exhausted, IMDS down, az
|
| 151 |
+
login expired), the hook must:
|
| 152 |
+
1. Log at WARNING level so the misconfiguration is visible at
|
| 153 |
+
default log level (not buried at DEBUG).
|
| 154 |
+
2. Strip any pre-set Authorization headers — including the
|
| 155 |
+
placeholder ``entra-id-bearer-via-http-hook`` sentinel that
|
| 156 |
+
:func:`_build_anthropic_client_with_bearer_hook` sets on the
|
| 157 |
+
Anthropic SDK constructor. This produces a clean
|
| 158 |
+
"missing auth" 401 from Azure rather than a sentinel-bearing
|
| 159 |
+
401 that's harder to diagnose AND avoids leaking the
|
| 160 |
+
sentinel string into upstream access logs.
|
| 161 |
+
"""
|
| 162 |
+
import logging
|
| 163 |
+
import httpx
|
| 164 |
+
from agent.azure_identity_adapter import build_bearer_http_client
|
| 165 |
+
|
| 166 |
+
def bad_provider():
|
| 167 |
+
return "" # empty token → materialize_bearer_for_http raises
|
| 168 |
+
|
| 169 |
+
client = build_bearer_http_client(bad_provider)
|
| 170 |
+
try:
|
| 171 |
+
hook = client.event_hooks["request"][0]
|
| 172 |
+
req = httpx.Request(
|
| 173 |
+
"POST", "https://example.com/v1/messages",
|
| 174 |
+
headers={
|
| 175 |
+
"Authorization": "Bearer entra-id-bearer-via-http-hook",
|
| 176 |
+
"api-key": "leaked-placeholder",
|
| 177 |
+
},
|
| 178 |
+
)
|
| 179 |
+
with caplog.at_level(logging.WARNING, logger="agent.azure_identity_adapter"):
|
| 180 |
+
hook(req) # Must not raise.
|
| 181 |
+
# Pre-set auth headers stripped — no sentinel makes it to Azure.
|
| 182 |
+
assert "Authorization" not in req.headers
|
| 183 |
+
assert "api-key" not in req.headers
|
| 184 |
+
# WARNING was logged so the user sees the misconfiguration.
|
| 185 |
+
assert any(
|
| 186 |
+
rec.levelno == logging.WARNING and "Entra ID token provider" in rec.message
|
| 187 |
+
for rec in caplog.records
|
| 188 |
+
)
|
| 189 |
+
finally:
|
| 190 |
+
client.close()
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class TestIsTokenProvider:
|
| 196 |
+
def test_callable_is_token_provider(self):
|
| 197 |
+
from agent.azure_identity_adapter import is_token_provider
|
| 198 |
+
assert is_token_provider(lambda: "x") is True
|
| 199 |
+
|
| 200 |
+
def test_string_is_not_token_provider(self):
|
| 201 |
+
from agent.azure_identity_adapter import is_token_provider
|
| 202 |
+
assert is_token_provider("static-key") is False
|
| 203 |
+
# ``str`` instances are technically callable in some edge cases
|
| 204 |
+
# — confirm they're never classified as token providers.
|
| 205 |
+
assert is_token_provider("") is False
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ---------------------------------------------------------------------------
|
| 209 |
+
# EntraIdentityConfig
|
| 210 |
+
# ---------------------------------------------------------------------------
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class TestEntraIdentityConfig:
|
| 214 |
+
"""The serializable config that crosses multiprocessing boundaries —
|
| 215 |
+
must round-trip through dict cleanly and never lose fields."""
|
| 216 |
+
|
| 217 |
+
def test_to_dict_round_trip(self):
|
| 218 |
+
from agent.azure_identity_adapter import EntraIdentityConfig
|
| 219 |
+
cfg = EntraIdentityConfig(
|
| 220 |
+
scope="https://ai.azure.com/.default",
|
| 221 |
+
exclude_interactive_browser=False,
|
| 222 |
+
)
|
| 223 |
+
rebuilt = EntraIdentityConfig.from_dict(cfg.to_dict())
|
| 224 |
+
assert rebuilt == cfg
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def test_dataclass_is_frozen(self):
|
| 231 |
+
# Frozen dataclasses are hashable / safe to pass through caches.
|
| 232 |
+
from agent.azure_identity_adapter import EntraIdentityConfig
|
| 233 |
+
cfg = EntraIdentityConfig()
|
| 234 |
+
with pytest.raises((AttributeError, Exception)):
|
| 235 |
+
setattr(cfg, "scope", "mutated")
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
# Credential / token provider construction
|
| 240 |
+
# ---------------------------------------------------------------------------
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class _FakeAzureIdentity:
|
| 244 |
+
"""Stand-in for the ``azure.identity`` module.
|
| 245 |
+
|
| 246 |
+
Captures kwargs passed to ``DefaultAzureCredential`` so tests can
|
| 247 |
+
assert how config flows into the SDK.
|
| 248 |
+
"""
|
| 249 |
+
|
| 250 |
+
def __init__(self):
|
| 251 |
+
self.last_credential_kwargs = None
|
| 252 |
+
self.last_scope = None
|
| 253 |
+
self.credential_count = 0
|
| 254 |
+
|
| 255 |
+
def DefaultAzureCredential(self, **kwargs): # noqa: N802 — match SDK
|
| 256 |
+
self.last_credential_kwargs = kwargs
|
| 257 |
+
self.credential_count += 1
|
| 258 |
+
return SimpleNamespace(
|
| 259 |
+
get_token=lambda scope: SimpleNamespace(token="fake-jwt", expires_on=9999999999),
|
| 260 |
+
kwargs=kwargs,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
def get_bearer_token_provider(self, credential, scope):
|
| 264 |
+
self.last_scope = scope
|
| 265 |
+
# Return a callable that mints a token when invoked.
|
| 266 |
+
return lambda: f"jwt-for-{scope}"
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
@pytest.fixture
|
| 270 |
+
def fake_azure_identity(monkeypatch):
|
| 271 |
+
"""Install a fake azure.identity into sys.modules and stub the
|
| 272 |
+
adapter's `_require_azure_identity` so all tests use the fake."""
|
| 273 |
+
fake = _FakeAzureIdentity()
|
| 274 |
+
|
| 275 |
+
fake_module = SimpleNamespace(
|
| 276 |
+
DefaultAzureCredential=fake.DefaultAzureCredential,
|
| 277 |
+
get_bearer_token_provider=fake.get_bearer_token_provider,
|
| 278 |
+
)
|
| 279 |
+
monkeypatch.setitem(sys.modules, "azure", SimpleNamespace(identity=fake_module))
|
| 280 |
+
monkeypatch.setitem(sys.modules, "azure.identity", fake_module)
|
| 281 |
+
|
| 282 |
+
# The adapter's `_require_azure_identity` does its own import, so
|
| 283 |
+
# patch that too to make sure tests never hit the real package's
|
| 284 |
+
# singleton state.
|
| 285 |
+
from agent import azure_identity_adapter as _adapter
|
| 286 |
+
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
|
| 287 |
+
|
| 288 |
+
return fake
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class TestBuildCredential:
|
| 292 |
+
def test_default_kwargs_are_minimal(self, fake_azure_identity):
|
| 293 |
+
"""SDK default for ``exclude_interactive_browser_credential`` is
|
| 294 |
+
True; we only pass it when the user opts IN to interactive
|
| 295 |
+
browser auth. Tenant / authority / service principal config
|
| 296 |
+
flow through the standard ``AZURE_*`` env vars (read by
|
| 297 |
+
azure-identity directly), not Hermes config kwargs."""
|
| 298 |
+
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
|
| 299 |
+
cred = build_credential(EntraIdentityConfig())
|
| 300 |
+
kwargs = fake_azure_identity.last_credential_kwargs
|
| 301 |
+
# Default config should produce empty kwargs — SDK uses its own
|
| 302 |
+
# defaults plus env-var-driven settings.
|
| 303 |
+
assert kwargs == {}
|
| 304 |
+
assert cred is not None
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def test_credential_is_cached_per_config(self, fake_azure_identity):
|
| 308 |
+
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
|
| 309 |
+
cfg = EntraIdentityConfig(scope="s1")
|
| 310 |
+
c1 = build_credential(cfg)
|
| 311 |
+
c2 = build_credential(cfg)
|
| 312 |
+
assert c1 is c2
|
| 313 |
+
assert fake_azure_identity.credential_count == 1
|
| 314 |
+
|
| 315 |
+
def test_distinct_configs_get_distinct_credentials(self, fake_azure_identity):
|
| 316 |
+
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
|
| 317 |
+
c1 = build_credential(EntraIdentityConfig(scope="s1"))
|
| 318 |
+
c2 = build_credential(EntraIdentityConfig(scope="s2"))
|
| 319 |
+
assert c1 is not c2
|
| 320 |
+
assert fake_azure_identity.credential_count == 2
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
class TestBuildTokenProvider:
|
| 325 |
+
def test_returns_callable_for_scope(self, fake_azure_identity):
|
| 326 |
+
from agent.azure_identity_adapter import build_token_provider
|
| 327 |
+
provider = build_token_provider(scope="https://ai.azure.com/.default")
|
| 328 |
+
assert callable(provider)
|
| 329 |
+
assert provider() == "jwt-for-https://ai.azure.com/.default"
|
| 330 |
+
assert fake_azure_identity.last_scope == "https://ai.azure.com/.default"
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def test_config_object_wins_over_kwargs(self, fake_azure_identity):
|
| 335 |
+
from agent.azure_identity_adapter import (
|
| 336 |
+
EntraIdentityConfig,
|
| 337 |
+
build_token_provider,
|
| 338 |
+
)
|
| 339 |
+
cfg = EntraIdentityConfig(scope="cfg-scope")
|
| 340 |
+
build_token_provider(scope="ignored", config=cfg)
|
| 341 |
+
assert fake_azure_identity.last_scope == "cfg-scope"
|
| 342 |
+
assert fake_azure_identity.last_credential_kwargs == {}
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
# ---------------------------------------------------------------------------
|
| 346 |
+
# Lazy-install / missing-package surface
|
| 347 |
+
# ---------------------------------------------------------------------------
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
class TestRequireAzureIdentityMissing:
|
| 351 |
+
def test_clear_error_when_lazy_install_disabled(self, monkeypatch):
|
| 352 |
+
"""When azure-identity isn't importable AND lazy installs are
|
| 353 |
+
off, the adapter must raise ImportError with an actionable
|
| 354 |
+
message, not propagate FeatureUnavailable."""
|
| 355 |
+
from agent import azure_identity_adapter as _adapter
|
| 356 |
+
|
| 357 |
+
# Force the import path to fail.
|
| 358 |
+
original_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __import__
|
| 359 |
+
def _fake_import(name, *args, **kwargs):
|
| 360 |
+
if name == "azure.identity" or name.startswith("azure.identity."):
|
| 361 |
+
raise ImportError("simulated missing azure-identity")
|
| 362 |
+
return original_import(name, *args, **kwargs)
|
| 363 |
+
|
| 364 |
+
monkeypatch.setattr("builtins.__import__", _fake_import)
|
| 365 |
+
|
| 366 |
+
# Simulate lazy installs disabled.
|
| 367 |
+
from tools.lazy_deps import FeatureUnavailable
|
| 368 |
+
|
| 369 |
+
def _fake_ensure(*args, **kwargs):
|
| 370 |
+
raise FeatureUnavailable(
|
| 371 |
+
"provider.azure_identity",
|
| 372 |
+
("azure-identity==1.25.3",),
|
| 373 |
+
"lazy installs disabled (test simulation)",
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
# The adapter calls ``ensure`` from ``tools.lazy_deps``; intercept
|
| 377 |
+
# it by patching the actual symbol path.
|
| 378 |
+
monkeypatch.setattr("tools.lazy_deps.ensure", _fake_ensure)
|
| 379 |
+
|
| 380 |
+
with pytest.raises(ImportError) as exc_info:
|
| 381 |
+
_adapter._require_azure_identity()
|
| 382 |
+
msg = str(exc_info.value)
|
| 383 |
+
assert "azure-identity" in msg
|
| 384 |
+
assert "Foundry" in msg or "foundry" in msg.lower()
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# ---------------------------------------------------------------------------
|
| 388 |
+
# has_azure_identity_credentials probe (timeout-bounded)
|
| 389 |
+
# ---------------------------------------------------------------------------
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
class TestHasAzureIdentityCredentials:
|
| 393 |
+
|
| 394 |
+
def test_lazy_install_triggered_when_package_missing(self, monkeypatch):
|
| 395 |
+
"""With allow_install=True (default), the probe must trigger the
|
| 396 |
+
lazy-install path before bailing — otherwise the wizard's
|
| 397 |
+
``preflight`` would silently fail for fresh installs that haven't
|
| 398 |
+
run ``pip install azure-identity`` yet."""
|
| 399 |
+
from agent import azure_identity_adapter as _adapter
|
| 400 |
+
|
| 401 |
+
installed = {"called": False}
|
| 402 |
+
|
| 403 |
+
def _fake_install():
|
| 404 |
+
installed["called"] = True
|
| 405 |
+
# After install, pretend the package is now importable.
|
| 406 |
+
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True)
|
| 407 |
+
return SimpleNamespace(
|
| 408 |
+
DefaultAzureCredential=lambda **kw: SimpleNamespace(
|
| 409 |
+
kwargs=kw,
|
| 410 |
+
get_token=lambda scope: SimpleNamespace(token="post-install-jwt", expires_on=0),
|
| 411 |
+
),
|
| 412 |
+
get_bearer_token_provider=lambda c, s: lambda: "x",
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
|
| 416 |
+
monkeypatch.setattr(_adapter, "_require_azure_identity", _fake_install)
|
| 417 |
+
|
| 418 |
+
# Provide a credential factory so the probe proceeds after install.
|
| 419 |
+
monkeypatch.setattr(
|
| 420 |
+
_adapter, "build_credential",
|
| 421 |
+
lambda config: SimpleNamespace(
|
| 422 |
+
get_token=lambda scope: SimpleNamespace(token="probe-jwt", expires_on=0),
|
| 423 |
+
),
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
result = _adapter.has_azure_identity_credentials(
|
| 427 |
+
"https://x/.default", timeout_seconds=0.5,
|
| 428 |
+
)
|
| 429 |
+
assert installed["called"] is True, (
|
| 430 |
+
"has_azure_identity_credentials must trigger lazy install "
|
| 431 |
+
"before bailing"
|
| 432 |
+
)
|
| 433 |
+
assert result is True
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def test_returns_false_on_timeout(self, monkeypatch):
|
| 438 |
+
"""Slow IMDS / network must time out, not hang the caller."""
|
| 439 |
+
import threading
|
| 440 |
+
from agent import azure_identity_adapter as _adapter
|
| 441 |
+
|
| 442 |
+
slow_release = threading.Event()
|
| 443 |
+
|
| 444 |
+
def _slow_credential(_config):
|
| 445 |
+
class _Cred:
|
| 446 |
+
def get_token(self, scope):
|
| 447 |
+
# Block forever from the test's perspective; the
|
| 448 |
+
# adapter must give up via its thread-bounded probe.
|
| 449 |
+
slow_release.wait(timeout=10)
|
| 450 |
+
return SimpleNamespace(token="never-returned", expires_on=0)
|
| 451 |
+
return _Cred()
|
| 452 |
+
|
| 453 |
+
monkeypatch.setattr(_adapter, "build_credential", _slow_credential)
|
| 454 |
+
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True)
|
| 455 |
+
try:
|
| 456 |
+
assert _adapter.has_azure_identity_credentials(
|
| 457 |
+
"https://x/.default", timeout_seconds=0.1
|
| 458 |
+
) is False
|
| 459 |
+
finally:
|
| 460 |
+
slow_release.set()
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
# ---------------------------------------------------------------------------
|
| 464 |
+
# describe_active_credential — used by hermes doctor + hermes auth
|
| 465 |
+
# ---------------------------------------------------------------------------
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
class TestDescribeActiveCredential:
|
| 469 |
+
|
| 470 |
+
def test_reports_install_failure(self, monkeypatch):
|
| 471 |
+
"""When lazy install is allowed but fails (e.g. lazy installs
|
| 472 |
+
disabled), the diagnostic surfaces the failure as the error."""
|
| 473 |
+
from agent import azure_identity_adapter as _adapter
|
| 474 |
+
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
|
| 475 |
+
|
| 476 |
+
def _fail_install():
|
| 477 |
+
raise ImportError("simulated: lazy installs disabled")
|
| 478 |
+
|
| 479 |
+
monkeypatch.setattr(_adapter, "_require_azure_identity", _fail_install)
|
| 480 |
+
info = _adapter.describe_active_credential(
|
| 481 |
+
scope="https://x/.default", allow_install=True,
|
| 482 |
+
)
|
| 483 |
+
assert info["ok"] is False
|
| 484 |
+
assert "lazy installs disabled" in info["error"]
|
| 485 |
+
assert "lazy" in info["hint"].lower()
|
| 486 |
+
|
| 487 |
+
def test_reports_env_sources_for_managed_identity(self, fake_azure_identity, monkeypatch):
|
| 488 |
+
from agent.azure_identity_adapter import describe_active_credential
|
| 489 |
+
monkeypatch.setenv("IDENTITY_ENDPOINT", "http://169.254.169.254")
|
| 490 |
+
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
|
| 491 |
+
assert info["ok"] is True
|
| 492 |
+
sources = info.get("env_sources") or []
|
| 493 |
+
assert any("ManagedIdentity" in s for s in sources)
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
|
tests/agent/test_background_review_input_budget.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for the background-review aggregate input budget (#93057).
|
| 2 |
+
|
| 3 |
+
The review fork replays its snapshot on every provider request in its tool
|
| 4 |
+
loop. Detached in-memory compaction bounds any SINGLE request; the aggregate
|
| 5 |
+
input budget (``_review_input_token_budget``, set by
|
| 6 |
+
``_run_review_in_thread`` from ``auxiliary.background_review.max_input_tokens``)
|
| 7 |
+
bounds the WHOLE review: the tool loop stops before the provider call that
|
| 8 |
+
would cross it, mirroring the iteration-budget exit.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from types import SimpleNamespace
|
| 14 |
+
from unittest.mock import MagicMock, patch
|
| 15 |
+
|
| 16 |
+
import pytest
|
| 17 |
+
|
| 18 |
+
from run_agent import AIAgent
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _tool_call() -> SimpleNamespace:
|
| 22 |
+
return SimpleNamespace(
|
| 23 |
+
id="call_1",
|
| 24 |
+
type="function",
|
| 25 |
+
function=SimpleNamespace(name="web_search", arguments='{"query": "x"}'),
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _tool_response(prompt_tokens: int) -> SimpleNamespace:
|
| 30 |
+
message = SimpleNamespace(
|
| 31 |
+
content=None,
|
| 32 |
+
reasoning_content=None,
|
| 33 |
+
reasoning=None,
|
| 34 |
+
tool_calls=[_tool_call()],
|
| 35 |
+
)
|
| 36 |
+
return SimpleNamespace(
|
| 37 |
+
choices=[SimpleNamespace(message=message, finish_reason="tool_calls")],
|
| 38 |
+
model="test/model",
|
| 39 |
+
usage=SimpleNamespace(
|
| 40 |
+
prompt_tokens=prompt_tokens,
|
| 41 |
+
completion_tokens=1,
|
| 42 |
+
total_tokens=prompt_tokens + 1,
|
| 43 |
+
),
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _final_response() -> SimpleNamespace:
|
| 48 |
+
message = SimpleNamespace(
|
| 49 |
+
content="done",
|
| 50 |
+
reasoning_content=None,
|
| 51 |
+
reasoning=None,
|
| 52 |
+
tool_calls=None,
|
| 53 |
+
)
|
| 54 |
+
return SimpleNamespace(
|
| 55 |
+
choices=[SimpleNamespace(message=message, finish_reason="stop")],
|
| 56 |
+
model="test/model",
|
| 57 |
+
usage=None,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _tool_definition() -> dict:
|
| 62 |
+
return {
|
| 63 |
+
"type": "function",
|
| 64 |
+
"function": {
|
| 65 |
+
"name": "web_search",
|
| 66 |
+
"description": "Search the web",
|
| 67 |
+
"parameters": {
|
| 68 |
+
"type": "object",
|
| 69 |
+
"properties": {"query": {"type": "string"}},
|
| 70 |
+
"required": ["query"],
|
| 71 |
+
},
|
| 72 |
+
},
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _make_loop_agent():
|
| 77 |
+
with (
|
| 78 |
+
patch("model_tools.get_tool_definitions", return_value=[_tool_definition()]),
|
| 79 |
+
patch("model_tools.check_toolset_requirements", return_value={}),
|
| 80 |
+
patch("agent.process_bootstrap.OpenAI"),
|
| 81 |
+
patch("agent.model_metadata.get_model_context_length", return_value=256_000),
|
| 82 |
+
patch("agent.context_compressor.get_model_context_length", return_value=256_000),
|
| 83 |
+
):
|
| 84 |
+
agent = AIAgent(
|
| 85 |
+
api_key="test-key-1234567890",
|
| 86 |
+
base_url="https://openrouter.ai/api/v1",
|
| 87 |
+
model="test/model",
|
| 88 |
+
quiet_mode=True,
|
| 89 |
+
skip_context_files=True,
|
| 90 |
+
skip_memory=True,
|
| 91 |
+
max_iterations=10,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
agent.client = MagicMock()
|
| 95 |
+
agent._cached_system_prompt = "You are helpful."
|
| 96 |
+
agent._use_prompt_caching = False
|
| 97 |
+
agent._disable_streaming = True
|
| 98 |
+
agent.tool_delay = 0
|
| 99 |
+
agent.save_trajectories = False
|
| 100 |
+
agent.max_compression_attempts = 1
|
| 101 |
+
|
| 102 |
+
compressor = MagicMock()
|
| 103 |
+
compressor.protect_first_n = 3
|
| 104 |
+
compressor.protect_last_n = 20
|
| 105 |
+
compressor.threshold_tokens = 999_999_999 # never fire compaction here
|
| 106 |
+
compressor.context_length = 1_000_000_000
|
| 107 |
+
compressor.last_prompt_tokens = -1
|
| 108 |
+
compressor._verify_compaction_cleared_threshold = False
|
| 109 |
+
compressor.awaiting_real_usage_after_compression = False
|
| 110 |
+
compressor.should_compress.return_value = False
|
| 111 |
+
compressor.should_compress_info.return_value = (False, None)
|
| 112 |
+
compressor.should_compress_preflight.return_value = False
|
| 113 |
+
compressor.should_defer_preflight_to_real_usage.return_value = False
|
| 114 |
+
compressor.get_active_compression_failure_cooldown.return_value = None
|
| 115 |
+
compressor.select_context.return_value = None
|
| 116 |
+
compressor.get_automatic_compaction_status_message.return_value = ""
|
| 117 |
+
agent.compression_enabled = False # isolate the budget behavior under test
|
| 118 |
+
agent.context_compressor = compressor
|
| 119 |
+
|
| 120 |
+
def _fake_execute_tool_calls(assistant_message, messages, *_args):
|
| 121 |
+
tool_call = assistant_message.tool_calls[0]
|
| 122 |
+
messages.append(
|
| 123 |
+
{
|
| 124 |
+
"role": "tool",
|
| 125 |
+
"name": tool_call.function.name,
|
| 126 |
+
"tool_call_id": tool_call.id,
|
| 127 |
+
"content": "ok",
|
| 128 |
+
}
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
agent._execute_tool_calls = _fake_execute_tool_calls
|
| 132 |
+
return agent
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _run_with_responses(agent, responses):
|
| 136 |
+
agent.client.chat.completions.create.side_effect = responses
|
| 137 |
+
with (
|
| 138 |
+
patch.object(agent, "_flush_messages_to_session_db", return_value=True),
|
| 139 |
+
patch.object(agent, "_persist_session"),
|
| 140 |
+
patch.object(agent, "_save_trajectory"),
|
| 141 |
+
patch.object(agent, "_cleanup_task_resources"),
|
| 142 |
+
):
|
| 143 |
+
result = agent.run_conversation("do some tool work")
|
| 144 |
+
return result
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_review_input_budget_stops_tool_loop_before_next_provider_call():
|
| 148 |
+
"""Once a fork's cumulative input crosses its budget, no further provider
|
| 149 |
+
call is made — the crossing request completes, then the loop stops."""
|
| 150 |
+
agent = _make_loop_agent()
|
| 151 |
+
agent._review_input_token_budget = 100_000
|
| 152 |
+
|
| 153 |
+
responses = [
|
| 154 |
+
_tool_response(50_000),
|
| 155 |
+
_tool_response(50_000), # cumulative 100_000 -> budget crossed
|
| 156 |
+
_tool_response(50_000), # must never be consumed
|
| 157 |
+
_final_response(),
|
| 158 |
+
]
|
| 159 |
+
result = _run_with_responses(agent, responses)
|
| 160 |
+
|
| 161 |
+
create = agent.client.chat.completions.create
|
| 162 |
+
assert create.call_count == 2, (
|
| 163 |
+
f"expected the loop to stop after crossing the input budget, "
|
| 164 |
+
f"but {create.call_count} provider calls were made (budget "
|
| 165 |
+
f"{agent._review_input_token_budget}, "
|
| 166 |
+
f"used {agent.session_input_tokens})"
|
| 167 |
+
)
|
| 168 |
+
assert agent.session_input_tokens == 100_000
|
| 169 |
+
assert result["completed"] is False
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_no_budget_attribute_leaves_tool_loop_unbounded():
|
| 173 |
+
"""Agents without ``_review_input_token_budget`` (every normal agent)
|
| 174 |
+
are unaffected by the gate and consume all scripted responses."""
|
| 175 |
+
agent = _make_loop_agent()
|
| 176 |
+
|
| 177 |
+
responses = [
|
| 178 |
+
_tool_response(50_000),
|
| 179 |
+
_tool_response(50_000),
|
| 180 |
+
_tool_response(50_000),
|
| 181 |
+
_final_response(),
|
| 182 |
+
]
|
| 183 |
+
result = _run_with_responses(agent, responses)
|
| 184 |
+
|
| 185 |
+
assert agent.client.chat.completions.create.call_count == 4
|
| 186 |
+
assert result["completed"] is True
|
| 187 |
+
assert result["final_response"] == "done"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_review_input_budget_exhausted_predicate_edge_cases():
|
| 191 |
+
"""The gate only arms for a positive int budget and a real token count."""
|
| 192 |
+
from agent.conversation_loop import _review_input_budget_exhausted
|
| 193 |
+
|
| 194 |
+
class _Agent:
|
| 195 |
+
pass
|
| 196 |
+
|
| 197 |
+
agent = _Agent()
|
| 198 |
+
assert _review_input_budget_exhausted(agent) is False
|
| 199 |
+
|
| 200 |
+
agent._review_input_token_budget = None
|
| 201 |
+
agent.session_input_tokens = 999_999
|
| 202 |
+
assert _review_input_budget_exhausted(agent) is False
|
| 203 |
+
|
| 204 |
+
agent._review_input_token_budget = 0
|
| 205 |
+
assert _review_input_budget_exhausted(agent) is False
|
| 206 |
+
|
| 207 |
+
agent._review_input_token_budget = -1
|
| 208 |
+
assert _review_input_budget_exhausted(agent) is False
|
| 209 |
+
|
| 210 |
+
agent._review_input_token_budget = "100"
|
| 211 |
+
assert _review_input_budget_exhausted(agent) is False
|
| 212 |
+
|
| 213 |
+
agent._review_input_token_budget = True
|
| 214 |
+
assert _review_input_budget_exhausted(agent) is False
|
| 215 |
+
|
| 216 |
+
agent._review_input_token_budget = 100_000
|
| 217 |
+
agent.session_input_tokens = 99_999
|
| 218 |
+
assert _review_input_budget_exhausted(agent) is False
|
| 219 |
+
|
| 220 |
+
agent.session_input_tokens = 100_000
|
| 221 |
+
assert _review_input_budget_exhausted(agent) is True
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@pytest.mark.parametrize(
|
| 225 |
+
("config_value", "expected"),
|
| 226 |
+
[
|
| 227 |
+
({}, 600_000),
|
| 228 |
+
({"max_input_tokens": 1_000_000}, 1_000_000),
|
| 229 |
+
({"max_input_tokens": 0}, None),
|
| 230 |
+
({"max_input_tokens": -5}, None),
|
| 231 |
+
({"max_input_tokens": "not-a-number"}, 600_000),
|
| 232 |
+
({"max_input_tokens": "300000"}, 300_000),
|
| 233 |
+
],
|
| 234 |
+
)
|
| 235 |
+
def test_review_input_token_budget_resolution(config_value, expected):
|
| 236 |
+
"""Config parsing: default, override, explicit disable, garbage fallback."""
|
| 237 |
+
from agent.background_review import _review_input_token_budget
|
| 238 |
+
|
| 239 |
+
assert _review_input_token_budget(config_value) == expected
|
tests/agent/test_background_review_summary.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for AIAgent._summarize_background_review_actions.
|
| 2 |
+
|
| 3 |
+
Regression coverage for issue #14944: the background memory/skill review used
|
| 4 |
+
to re-surface tool results that were already present in the conversation
|
| 5 |
+
history before the review started (e.g. an earlier "Cron job '...' created.").
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
from run_agent import AIAgent
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
_summarize = AIAgent._summarize_background_review_actions
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _tool_msg(tool_call_id, payload):
|
| 17 |
+
return {
|
| 18 |
+
"role": "tool",
|
| 19 |
+
"tool_call_id": tool_call_id,
|
| 20 |
+
"content": json.dumps(payload),
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_skips_prior_tool_messages_by_tool_call_id():
|
| 25 |
+
"""Stale 'created' tool result from prior history must not be re-surfaced."""
|
| 26 |
+
prior_payload = {"success": True, "message": "Cron job 'remind-me' created."}
|
| 27 |
+
new_payload = {
|
| 28 |
+
"success": True,
|
| 29 |
+
"message": "Entry added",
|
| 30 |
+
"target": "user",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
snapshot = [
|
| 34 |
+
{"role": "user", "content": "create a reminder"},
|
| 35 |
+
_tool_msg("call_old", prior_payload),
|
| 36 |
+
{"role": "assistant", "content": "done"},
|
| 37 |
+
]
|
| 38 |
+
review_messages = list(snapshot) + [
|
| 39 |
+
{"role": "user", "content": "<review prompt>"},
|
| 40 |
+
_tool_msg("call_new", new_payload),
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
actions = _summarize(review_messages, snapshot)
|
| 44 |
+
|
| 45 |
+
assert "Cron job 'remind-me' created." not in actions
|
| 46 |
+
assert "User profile updated" in actions
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_includes_genuinely_new_actions():
|
| 50 |
+
new_payload = {
|
| 51 |
+
"success": True,
|
| 52 |
+
"message": "Memory entry created.",
|
| 53 |
+
}
|
| 54 |
+
review_messages = [_tool_msg("call_new", new_payload)]
|
| 55 |
+
|
| 56 |
+
actions = _summarize(review_messages, prior_snapshot=[])
|
| 57 |
+
|
| 58 |
+
assert actions == ["Memory entry created."]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_falls_back_to_content_equality_when_tool_call_id_missing():
|
| 62 |
+
"""If a tool message has no tool_call_id, match prior entries by content."""
|
| 63 |
+
payload = {"success": True, "message": "Cron job 'X' created."}
|
| 64 |
+
raw = json.dumps(payload)
|
| 65 |
+
prior_msg = {"role": "tool", "content": raw} # no tool_call_id
|
| 66 |
+
review_messages = [
|
| 67 |
+
{"role": "tool", "content": raw}, # same content -> stale, skip
|
| 68 |
+
_tool_msg("call_new", {"success": True, "message": "Skill created."}),
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
actions = _summarize(review_messages, [prior_msg])
|
| 72 |
+
|
| 73 |
+
assert "Cron job 'X' created." not in actions
|
| 74 |
+
assert "Skill created." in actions
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_handles_non_json_tool_content_gracefully():
|
| 80 |
+
review_messages = [
|
| 81 |
+
{"role": "tool", "tool_call_id": "x", "content": "not-json"},
|
| 82 |
+
_tool_msg("call_y", {"success": True, "message": "Memory updated."}),
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
actions = _summarize(review_messages, [])
|
| 86 |
+
|
| 87 |
+
assert actions == ["Memory updated."]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_empty_inputs():
|
| 91 |
+
assert _summarize([], []) == []
|
| 92 |
+
assert _summarize(None, None) == []
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_removed_or_replaced_relabels_by_target():
|
| 98 |
+
review_messages = [
|
| 99 |
+
_tool_msg(
|
| 100 |
+
"c1",
|
| 101 |
+
{"success": True, "message": "Entry removed.", "target": "user"},
|
| 102 |
+
),
|
| 103 |
+
_tool_msg(
|
| 104 |
+
"c2",
|
| 105 |
+
{"success": True, "message": "Entry replaced.", "target": "memory"},
|
| 106 |
+
),
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
actions = _summarize(review_messages, [])
|
| 110 |
+
|
| 111 |
+
assert "User profile updated" in actions
|
| 112 |
+
assert "Memory updated" in actions
|
tests/agent/test_background_review_tool_call_guard.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The background-review fork must not spawn when it could only no-op.
|
| 2 |
+
|
| 3 |
+
The review fork's whole job is to emit ``memory`` / ``skill_manage`` tool calls,
|
| 4 |
+
and by default it inherits the parent's live runtime. When the parent provider IS
|
| 5 |
+
an autonomous agent reached through a client shim that cannot carry Hermes tool
|
| 6 |
+
calls back, that fork is a guaranteed no-op — one that still pays for a full
|
| 7 |
+
agent spawn (a whole CLI process, sometimes a JVM) on every review cadence.
|
| 8 |
+
|
| 9 |
+
So: a client declaring ``SUPPORTS_HERMES_TOOL_CALLS = False`` skips the fork with
|
| 10 |
+
a log line pointing at the ``auxiliary.background_review`` override. A client
|
| 11 |
+
that can emit tool calls is unaffected, as are ordinary providers whose clients
|
| 12 |
+
say nothing at all.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
from types import SimpleNamespace
|
| 21 |
+
from unittest.mock import MagicMock, patch
|
| 22 |
+
|
| 23 |
+
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 24 |
+
if _REPO_ROOT not in sys.path:
|
| 25 |
+
sys.path.insert(0, _REPO_ROOT)
|
| 26 |
+
|
| 27 |
+
import agent.background_review as bg # noqa: E402
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _fake_parent(client, *, runtime=None) -> SimpleNamespace:
|
| 31 |
+
"""The minimal parent-agent surface _run_review_in_thread touches pre-fork."""
|
| 32 |
+
return SimpleNamespace(
|
| 33 |
+
provider="acp-agent",
|
| 34 |
+
model="acp-agent",
|
| 35 |
+
client=client,
|
| 36 |
+
session_id="s1",
|
| 37 |
+
platform="cli",
|
| 38 |
+
request_overrides={},
|
| 39 |
+
max_tokens=None,
|
| 40 |
+
acp_command="acp-agent",
|
| 41 |
+
acp_args=["--acp"],
|
| 42 |
+
enabled_toolsets=None,
|
| 43 |
+
disabled_toolsets=None,
|
| 44 |
+
reasoning_config=None,
|
| 45 |
+
_credential_pool=None,
|
| 46 |
+
_current_main_runtime=lambda: runtime or {
|
| 47 |
+
"api_key": "k",
|
| 48 |
+
"base_url": "acp://agent",
|
| 49 |
+
"api_mode": "chat_completions",
|
| 50 |
+
},
|
| 51 |
+
_emit_auxiliary_failure=lambda *_a, **_k: None,
|
| 52 |
+
_safe_print=lambda *_a, **_k: None,
|
| 53 |
+
background_review_callback=None,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _run(agent, task_cfg=None):
|
| 58 |
+
"""Run the worker with AIAgent patched; return the AIAgent mock."""
|
| 59 |
+
with (
|
| 60 |
+
patch("hermes_cli.config.load_config", return_value={}),
|
| 61 |
+
patch("run_agent.AIAgent") as mock_aiagent,
|
| 62 |
+
patch("tools.terminal_tool.set_approval_callback"),
|
| 63 |
+
):
|
| 64 |
+
bg._run_review_in_thread(
|
| 65 |
+
agent, [{"role": "user", "content": "hi"}], "review please", task_cfg
|
| 66 |
+
)
|
| 67 |
+
return mock_aiagent
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_fork_is_skipped_when_the_provider_cannot_emit_tool_calls(caplog):
|
| 71 |
+
client = MagicMock()
|
| 72 |
+
client.SUPPORTS_HERMES_TOOL_CALLS = False
|
| 73 |
+
with caplog.at_level(logging.WARNING, logger=bg.logger.name):
|
| 74 |
+
mock_aiagent = _run(_fake_parent(client))
|
| 75 |
+
mock_aiagent.assert_not_called()
|
| 76 |
+
# The user needs to know which knob makes the review work again.
|
| 77 |
+
assert "auxiliary.background_review" in caplog.text
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_fork_is_spawned_when_the_provider_can_emit_tool_calls():
|
| 81 |
+
client = MagicMock()
|
| 82 |
+
client.SUPPORTS_HERMES_TOOL_CALLS = True
|
| 83 |
+
assert _run(_fake_parent(client)).called
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_ordinary_providers_are_unaffected():
|
| 87 |
+
# A plain OpenAI-style client says nothing about the capability.
|
| 88 |
+
class _PlainClient:
|
| 89 |
+
pass
|
| 90 |
+
|
| 91 |
+
assert _run(_fake_parent(_PlainClient())).called
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_the_capability_is_read_off_the_class_too():
|
| 95 |
+
"""Clients declare it as a class attribute; an instance need not set it."""
|
| 96 |
+
|
| 97 |
+
class _IncapableClient:
|
| 98 |
+
SUPPORTS_HERMES_TOOL_CALLS = False
|
| 99 |
+
|
| 100 |
+
assert bg._parent_can_emit_tool_calls(_fake_parent(_IncapableClient())) is False
|
| 101 |
+
assert bg._parent_can_emit_tool_calls(_fake_parent(None)) is True
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_an_incapable_provider_still_reviews_when_the_review_is_routed_away():
|
| 105 |
+
"""``auxiliary.background_review.{provider,model}`` sends the fork to a normal
|
| 106 |
+
model, so the parent's shim no longer matters."""
|
| 107 |
+
|
| 108 |
+
class _IncapableClient:
|
| 109 |
+
SUPPORTS_HERMES_TOOL_CALLS = False
|
| 110 |
+
|
| 111 |
+
routed = {
|
| 112 |
+
"provider": "openai",
|
| 113 |
+
"model": "gpt-5",
|
| 114 |
+
"api_key": "k",
|
| 115 |
+
"base_url": None,
|
| 116 |
+
"api_mode": "chat_completions",
|
| 117 |
+
"credential_pool": None,
|
| 118 |
+
"request_overrides": {},
|
| 119 |
+
"max_tokens": None,
|
| 120 |
+
"command": None,
|
| 121 |
+
"args": [],
|
| 122 |
+
"routed": True,
|
| 123 |
+
}
|
| 124 |
+
with patch.object(bg, "_resolve_review_runtime", return_value=routed):
|
| 125 |
+
assert _run(_fake_parent(_IncapableClient())).called
|
tests/agent/test_bedrock_empty_text_blocks.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for Bedrock Converse empty/whitespace text block rejection.
|
| 2 |
+
|
| 3 |
+
Bedrock's Converse API raises::
|
| 4 |
+
|
| 5 |
+
ValidationException: The model returned the following errors: messages:
|
| 6 |
+
text content blocks must contain non-whitespace text
|
| 7 |
+
|
| 8 |
+
for ANY text content block that is empty OR whitespace-only. Anthropic's native
|
| 9 |
+
API tolerates these; Bedrock does not. Such blocks most often appear after
|
| 10 |
+
context compression rewrites an assistant/tool turn into a blank string — once
|
| 11 |
+
in history the block is re-sent on every call and fails deterministically
|
| 12 |
+
(all retries fail identically). Ref: issue #9486.
|
| 13 |
+
|
| 14 |
+
These tests assert convert_messages_to_converse never emits a blank text block
|
| 15 |
+
(including inside toolResult content) and never uses a whitespace-only
|
| 16 |
+
placeholder (a lone space would be rejected by the same validation).
|
| 17 |
+
"""
|
| 18 |
+
import pytest
|
| 19 |
+
|
| 20 |
+
from agent.bedrock_adapter import (
|
| 21 |
+
convert_messages_to_converse,
|
| 22 |
+
_convert_content_to_converse,
|
| 23 |
+
_safe_text,
|
| 24 |
+
_EMPTY_TEXT_PLACEHOLDER,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _iter_text_blocks(msgs):
|
| 29 |
+
"""Yield every text string that will be sent to Bedrock, incl. toolResult."""
|
| 30 |
+
for m in msgs:
|
| 31 |
+
for b in m["content"]:
|
| 32 |
+
if "text" in b:
|
| 33 |
+
yield b["text"]
|
| 34 |
+
if "toolResult" in b:
|
| 35 |
+
for tb in b["toolResult"]["content"]:
|
| 36 |
+
if "text" in tb:
|
| 37 |
+
yield tb["text"]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_safe_text_preserves_real_content():
|
| 45 |
+
assert _safe_text("hello") == "hello"
|
| 46 |
+
assert _safe_text(" padded ") == " padded " # inner content kept verbatim
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_real_content_is_preserved_alongside_blank_siblings():
|
| 54 |
+
messages = [
|
| 55 |
+
{"role": "user", "content": [
|
| 56 |
+
{"type": "text", "text": " "},
|
| 57 |
+
{"type": "text", "text": "real question"},
|
| 58 |
+
]},
|
| 59 |
+
]
|
| 60 |
+
_system, msgs = convert_messages_to_converse(messages)
|
| 61 |
+
texts = list(_iter_text_blocks(msgs))
|
| 62 |
+
assert "real question" in texts
|
| 63 |
+
assert all(t.strip() for t in texts)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_blank_system_blocks_dropped_not_blanked():
|
| 67 |
+
messages = [
|
| 68 |
+
{"role": "system", "content": [
|
| 69 |
+
{"type": "text", "text": "keep me"},
|
| 70 |
+
{"type": "text", "text": " "},
|
| 71 |
+
]},
|
| 72 |
+
{"role": "user", "content": "hi"},
|
| 73 |
+
]
|
| 74 |
+
system, _msgs = convert_messages_to_converse(messages)
|
| 75 |
+
assert system is not None
|
| 76 |
+
for block in system:
|
| 77 |
+
assert block["text"].strip()
|
| 78 |
+
assert any(b["text"] == "keep me" for b in system)
|
tests/agent/test_billing_unverified_carrythrough.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""#82154: an unverified billing verdict must carry its ambiguity through
|
| 2 |
+
every downstream surface — the returned terminal response, the structured
|
| 3 |
+
result fields, the credential-pool failure_reason, and the persisted entry —
|
| 4 |
+
not just the explanatory guidance text.
|
| 5 |
+
|
| 6 |
+
Anthropic returns the identical "out of extra usage" HTTP 400 body on a
|
| 7 |
+
subscription OAuth token both for genuine overage depletion and for a
|
| 8 |
+
server-side content-filter rejection of the request. The classifier marks
|
| 9 |
+
that verdict ``billing_unverified``; these tests pin that the marking is not
|
| 10 |
+
dropped on the way out.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from types import SimpleNamespace
|
| 16 |
+
from unittest.mock import MagicMock
|
| 17 |
+
|
| 18 |
+
from agent.conversation_loop import _billing_failure_result, _billing_terminal_label
|
| 19 |
+
from agent.error_classifier import FailoverReason, classify_api_error
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class MockAPIError(Exception):
|
| 23 |
+
def __init__(self, message, status_code=None, body=None):
|
| 24 |
+
super().__init__(message)
|
| 25 |
+
self.status_code = status_code
|
| 26 |
+
self.body = body
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
_EXTRA_USAGE_BODY = (
|
| 30 |
+
"You're out of extra usage. Add more at claude.ai/settings/usage and keep going."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _classified_unverified():
|
| 35 |
+
e = MockAPIError(
|
| 36 |
+
_EXTRA_USAGE_BODY,
|
| 37 |
+
status_code=400,
|
| 38 |
+
body={"error": {"type": "invalid_request_error", "message": _EXTRA_USAGE_BODY}},
|
| 39 |
+
)
|
| 40 |
+
return classify_api_error(e, provider="anthropic")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _classified_confirmed():
|
| 44 |
+
e = MockAPIError(
|
| 45 |
+
"Your credit balance is too low to access the Anthropic API.",
|
| 46 |
+
status_code=400,
|
| 47 |
+
body={"error": {
|
| 48 |
+
"type": "invalid_request_error",
|
| 49 |
+
"message": "Your credit balance is too low to access the Anthropic API.",
|
| 50 |
+
}},
|
| 51 |
+
)
|
| 52 |
+
return classify_api_error(e, provider="anthropic")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ── Returned terminal response ───────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class TestTerminalResponse:
|
| 59 |
+
def test_unverified_terminal_response_does_not_assert_billing(self):
|
| 60 |
+
"""The exact ambiguous 400 must not produce an unhedged
|
| 61 |
+
'Billing or credits exhausted' terminal response."""
|
| 62 |
+
result = _billing_failure_result(
|
| 63 |
+
classified=_classified_unverified(),
|
| 64 |
+
summary="HTTP 400: out of extra usage",
|
| 65 |
+
messages=[],
|
| 66 |
+
api_call_count=3,
|
| 67 |
+
provider="anthropic",
|
| 68 |
+
base_url="https://api.anthropic.com",
|
| 69 |
+
model="claude-opus-5",
|
| 70 |
+
)
|
| 71 |
+
final = result["final_response"]
|
| 72 |
+
assert not final.startswith("Billing or credits exhausted")
|
| 73 |
+
assert "unverified" in final
|
| 74 |
+
assert "content-filter" in final or "content filter" in final
|
| 75 |
+
# The guidance must ride along and hedge too.
|
| 76 |
+
assert "still shows quota remaining" in final
|
| 77 |
+
|
| 78 |
+
def test_unverified_terminal_response_structured_fields(self):
|
| 79 |
+
"""The structured result carries the ambiguity, not just the prose."""
|
| 80 |
+
result = _billing_failure_result(
|
| 81 |
+
classified=_classified_unverified(),
|
| 82 |
+
summary="HTTP 400: out of extra usage",
|
| 83 |
+
messages=[],
|
| 84 |
+
api_call_count=3,
|
| 85 |
+
provider="anthropic",
|
| 86 |
+
base_url="https://api.anthropic.com",
|
| 87 |
+
model="claude-opus-5",
|
| 88 |
+
)
|
| 89 |
+
assert result["failed"] is True
|
| 90 |
+
assert result["failure_reason"] == "billing"
|
| 91 |
+
assert result["billing_unverified"] is True
|
| 92 |
+
block = result["billing_block"]
|
| 93 |
+
if block is not None: # None only if billing_links is unavailable
|
| 94 |
+
assert block.get("unverified") is True
|
| 95 |
+
|
| 96 |
+
def test_confirmed_terminal_response_stays_assertive(self):
|
| 97 |
+
"""A confirmed billing verdict keeps the original terminal label and
|
| 98 |
+
carries no ambiguity flag."""
|
| 99 |
+
result = _billing_failure_result(
|
| 100 |
+
classified=_classified_confirmed(),
|
| 101 |
+
summary="HTTP 400: credit balance too low",
|
| 102 |
+
messages=[],
|
| 103 |
+
api_call_count=1,
|
| 104 |
+
provider="anthropic",
|
| 105 |
+
base_url="https://api.anthropic.com",
|
| 106 |
+
model="claude-opus-5",
|
| 107 |
+
)
|
| 108 |
+
assert result["final_response"].startswith("Billing or credits exhausted")
|
| 109 |
+
assert result["billing_unverified"] is False
|
| 110 |
+
block = result["billing_block"]
|
| 111 |
+
if block is not None:
|
| 112 |
+
assert "unverified" not in block
|
| 113 |
+
|
| 114 |
+
def test_terminal_label_contract(self):
|
| 115 |
+
assert _billing_terminal_label("boom", False) == "Billing or credits exhausted: boom"
|
| 116 |
+
hedged = _billing_terminal_label("boom", True)
|
| 117 |
+
assert "unverified" in hedged
|
| 118 |
+
assert "content-filter" in hedged
|
| 119 |
+
assert not hedged.startswith("Billing or credits exhausted")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ── Credential-pool plumbing ─────────────────────────────────────────────────
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class TestPoolFailureReason:
|
| 126 |
+
def _run_recovery(self, *, billing_unverified: bool) -> dict:
|
| 127 |
+
"""Drive recover_with_credential_pool with a billing classification and
|
| 128 |
+
capture what the pool is told."""
|
| 129 |
+
from agent.agent_runtime_helpers import recover_with_credential_pool
|
| 130 |
+
|
| 131 |
+
captured: dict = {}
|
| 132 |
+
next_entry = SimpleNamespace(label="secondary")
|
| 133 |
+
|
| 134 |
+
class _Pool:
|
| 135 |
+
provider = "anthropic"
|
| 136 |
+
|
| 137 |
+
def current(self):
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
def entries(self):
|
| 141 |
+
return []
|
| 142 |
+
|
| 143 |
+
def mark_exhausted_and_rotate(self, **kwargs):
|
| 144 |
+
captured.update(kwargs)
|
| 145 |
+
return next_entry
|
| 146 |
+
|
| 147 |
+
agent = SimpleNamespace(
|
| 148 |
+
provider="anthropic",
|
| 149 |
+
base_url="https://api.anthropic.com",
|
| 150 |
+
api_key="sk-ant-oat01-test",
|
| 151 |
+
_credential_pool=_Pool(),
|
| 152 |
+
_credential_pool_entry_id=None,
|
| 153 |
+
_swap_credential=MagicMock(),
|
| 154 |
+
)
|
| 155 |
+
recovered, _ = recover_with_credential_pool(
|
| 156 |
+
agent,
|
| 157 |
+
status_code=400,
|
| 158 |
+
has_retried_429=False,
|
| 159 |
+
classified_reason=FailoverReason.billing,
|
| 160 |
+
billing_unverified=billing_unverified,
|
| 161 |
+
)
|
| 162 |
+
assert recovered is True
|
| 163 |
+
return captured
|
| 164 |
+
|
| 165 |
+
def test_unverified_billing_reaches_pool_as_unverified(self):
|
| 166 |
+
captured = self._run_recovery(billing_unverified=True)
|
| 167 |
+
assert captured["failure_reason"] == "billing_unverified"
|
| 168 |
+
|
| 169 |
+
def test_confirmed_billing_reaches_pool_as_billing(self):
|
| 170 |
+
captured = self._run_recovery(billing_unverified=False)
|
| 171 |
+
assert captured["failure_reason"] == "billing"
|
tests/agent/test_billing_view.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the Phase 2b Remote Spending core + HTTP client.
|
| 2 |
+
|
| 3 |
+
Covers:
|
| 4 |
+
- Decimal money parsing/formatting (server emits decimal strings, not 2dp).
|
| 5 |
+
- BillingState payload parsing (role tiering, presets, bounds, sub-structs).
|
| 6 |
+
- Error-code → typed-exception mapping (the live-verified contract matrix).
|
| 7 |
+
- Fail-open builder behavior.
|
| 8 |
+
- Idempotency key generation.
|
| 9 |
+
- Custom-amount validation against bounds + multipleOf 0.01.
|
| 10 |
+
|
| 11 |
+
No network: HTTP-layer tests drive _raise_for_error directly and monkeypatch the
|
| 12 |
+
request function for the builder.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from decimal import Decimal
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
import agent.billing_view as bv
|
| 22 |
+
from agent.billing_view import (
|
| 23 |
+
AutoReload,
|
| 24 |
+
AutoReloadCard,
|
| 25 |
+
BillingState,
|
| 26 |
+
CardInfo,
|
| 27 |
+
MonthlyCap,
|
| 28 |
+
PaymentMethodInfo,
|
| 29 |
+
billing_state_from_payload,
|
| 30 |
+
build_billing_state,
|
| 31 |
+
format_money,
|
| 32 |
+
new_idempotency_key,
|
| 33 |
+
parse_money,
|
| 34 |
+
validate_charge_amount,
|
| 35 |
+
)
|
| 36 |
+
import hermes_cli.nous_billing as nb
|
| 37 |
+
from hermes_cli.nous_billing import (
|
| 38 |
+
BillingAuthError,
|
| 39 |
+
BillingError,
|
| 40 |
+
BillingRateLimited,
|
| 41 |
+
BillingScopeRequired,
|
| 42 |
+
BillingStripeUnavailable,
|
| 43 |
+
BillingTransient,
|
| 44 |
+
BillingUpgradeCapExceeded,
|
| 45 |
+
_raise_for_error,
|
| 46 |
+
resolve_portal_base_url,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Decimal money
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
# BillingState payload parsing
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _member_payload() -> dict:
|
| 69 |
+
return {
|
| 70 |
+
"org": {"id": "o1", "slug": "acme", "name": "Acme", "role": "MEMBER"},
|
| 71 |
+
"balanceUsd": "142.5",
|
| 72 |
+
"cliBillingEnabled": True,
|
| 73 |
+
"chargePresets": ["100", "250", "500"],
|
| 74 |
+
"bounds": {"minUsd": "10", "maxUsd": "10000"},
|
| 75 |
+
"card": None,
|
| 76 |
+
"monthlyCap": None,
|
| 77 |
+
"autoReload": None,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _owner_payload() -> dict:
|
| 82 |
+
p = _member_payload()
|
| 83 |
+
p["org"]["role"] = "OWNER"
|
| 84 |
+
p["card"] = {"brand": "visa", "last4": "4242"}
|
| 85 |
+
p["monthlyCap"] = {
|
| 86 |
+
"limitUsd": "1000",
|
| 87 |
+
"spentThisMonthUsd": "180",
|
| 88 |
+
"isDefaultCeiling": True,
|
| 89 |
+
}
|
| 90 |
+
p["autoReload"] = {"enabled": True, "thresholdUsd": "20", "reloadToUsd": "100"}
|
| 91 |
+
return p
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_state_member_tier_parse():
|
| 95 |
+
s = billing_state_from_payload(_member_payload())
|
| 96 |
+
assert s.logged_in
|
| 97 |
+
assert s.role == "MEMBER"
|
| 98 |
+
assert s.balance_usd == Decimal("142.5")
|
| 99 |
+
assert s.cli_billing_enabled is True
|
| 100 |
+
assert s.charge_presets == (Decimal("100"), Decimal("250"), Decimal("500"))
|
| 101 |
+
assert s.min_usd == Decimal("10") and s.max_usd == Decimal("10000")
|
| 102 |
+
assert s.card is None and s.monthly_cap is None and s.auto_reload is None
|
| 103 |
+
assert s.is_admin is False
|
| 104 |
+
assert s.can_charge is False # not admin
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@pytest.mark.parametrize(
|
| 108 |
+
"role,can_change_plan_raw,is_admin,can_change_plan",
|
| 109 |
+
[
|
| 110 |
+
("OWNER", None, True, True),
|
| 111 |
+
("ADMIN", None, True, True),
|
| 112 |
+
("FINANCE_ADMIN", True, False, True),
|
| 113 |
+
("SECURITY_ADMIN", None, False, False),
|
| 114 |
+
("MEMBER", None, False, False),
|
| 115 |
+
],
|
| 116 |
+
)
|
| 117 |
+
def test_state_five_roles(
|
| 118 |
+
role, can_change_plan_raw, is_admin, can_change_plan
|
| 119 |
+
):
|
| 120 |
+
payload = _member_payload()
|
| 121 |
+
payload["org"]["role"] = role
|
| 122 |
+
if can_change_plan_raw is not None:
|
| 123 |
+
payload["canChangePlan"] = can_change_plan_raw
|
| 124 |
+
|
| 125 |
+
state = billing_state_from_payload(payload)
|
| 126 |
+
|
| 127 |
+
assert state.is_admin is is_admin
|
| 128 |
+
assert state.can_change_plan_raw is can_change_plan_raw
|
| 129 |
+
assert state.can_change_plan is can_change_plan
|
| 130 |
+
if role == "SECURITY_ADMIN":
|
| 131 |
+
assert state.card is None
|
| 132 |
+
assert state.monthly_cap is None
|
| 133 |
+
assert state.auto_reload is None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_state_owner_tier_parse():
|
| 143 |
+
s = billing_state_from_payload(_owner_payload())
|
| 144 |
+
assert s.is_admin is True
|
| 145 |
+
assert s.can_charge is True # admin + kill-switch on
|
| 146 |
+
assert s.card == CardInfo(brand="visa", last4="4242")
|
| 147 |
+
assert s.card is not None and s.card.masked == "visa ····4242"
|
| 148 |
+
assert s.monthly_cap == MonthlyCap(
|
| 149 |
+
limit_usd=Decimal("1000"),
|
| 150 |
+
spent_this_month_usd=Decimal("180"),
|
| 151 |
+
is_default_ceiling=True,
|
| 152 |
+
)
|
| 153 |
+
assert s.auto_reload == AutoReload(
|
| 154 |
+
enabled=True, threshold_usd=Decimal("20"), reload_to_usd=Decimal("100")
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ---------------------------------------------------------------------------
|
| 173 |
+
# Error-code → typed-exception mapping (live-verified contract)
|
| 174 |
+
# ---------------------------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class _Headers:
|
| 178 |
+
def __init__(self, d):
|
| 179 |
+
self._d = d
|
| 180 |
+
|
| 181 |
+
def get(self, k):
|
| 182 |
+
return self._d.get(k)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_401_maps_to_auth_error():
|
| 186 |
+
with pytest.raises(BillingAuthError) as ei:
|
| 187 |
+
_raise_for_error(401, {"error": "invalid_token"})
|
| 188 |
+
assert ei.value.status == 401
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_403_insufficient_scope_maps_to_scope_required():
|
| 192 |
+
with pytest.raises(BillingScopeRequired) as ei:
|
| 193 |
+
_raise_for_error(403, {"error": "insufficient_scope", "portalUrl": "/billing"})
|
| 194 |
+
assert ei.value.error == "insufficient_scope"
|
| 195 |
+
# portalUrl is resolved to an absolute URL (relative-by-design from the server).
|
| 196 |
+
assert (ei.value.portal_url or "").startswith("http")
|
| 197 |
+
assert (ei.value.portal_url or "").endswith("/billing")
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@pytest.mark.parametrize(
|
| 203 |
+
"status,error,expected_type",
|
| 204 |
+
[
|
| 205 |
+
(503, "stripe_unavailable", BillingStripeUnavailable),
|
| 206 |
+
(429, "upgrade_cap_exceeded", BillingUpgradeCapExceeded),
|
| 207 |
+
],
|
| 208 |
+
)
|
| 209 |
+
def test_specific_billing_throttle_errors_remain_distinguishable(
|
| 210 |
+
status, error, expected_type
|
| 211 |
+
):
|
| 212 |
+
with pytest.raises(expected_type) as ei:
|
| 213 |
+
_raise_for_error(
|
| 214 |
+
status,
|
| 215 |
+
{"error": error},
|
| 216 |
+
_Headers({"Retry-After": "90"}),
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
assert ei.value.error == error
|
| 220 |
+
assert ei.value.retry_after == 90
|
| 221 |
+
assert isinstance(ei.value, BillingTransient)
|
| 222 |
+
assert not isinstance(ei.value, BillingRateLimited)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_400_amount_out_of_bounds_is_base_error():
|
| 230 |
+
with pytest.raises(BillingError) as ei:
|
| 231 |
+
_raise_for_error(400, {"error": "amount_out_of_bounds", "message": "too big"})
|
| 232 |
+
assert ei.value.status == 400
|
| 233 |
+
assert "too big" in str(ei.value)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ---------------------------------------------------------------------------
|
| 237 |
+
# post_charge requires idempotency key (client-side guard)
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# ---------------------------------------------------------------------------
|
| 246 |
+
# Base-URL resolution precedence
|
| 247 |
+
# ---------------------------------------------------------------------------
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def test_portal_base_url_default(monkeypatch):
|
| 255 |
+
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
|
| 256 |
+
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
|
| 257 |
+
assert resolve_portal_base_url() == nb.DEFAULT_PORTAL_BASE_URL
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ---------------------------------------------------------------------------
|
| 261 |
+
# Fail-open builder
|
| 262 |
+
# ---------------------------------------------------------------------------
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def test_build_billing_state_fail_open_on_http_error(monkeypatch):
|
| 268 |
+
def _boom(*a, **kw):
|
| 269 |
+
raise BillingError("portal exploded", status=500)
|
| 270 |
+
|
| 271 |
+
monkeypatch.setattr(nb, "get_billing_state", _boom)
|
| 272 |
+
s = build_billing_state()
|
| 273 |
+
assert s.logged_in is False
|
| 274 |
+
assert "portal exploded" in (s.error or "")
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ---------------------------------------------------------------------------
|
| 282 |
+
# Idempotency
|
| 283 |
+
# ---------------------------------------------------------------------------
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def test_new_idempotency_key_unique_and_uuid_shaped():
|
| 287 |
+
a, b = new_idempotency_key(), new_idempotency_key()
|
| 288 |
+
assert a != b
|
| 289 |
+
assert len(a) == 36 and a.count("-") == 4
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
# ---------------------------------------------------------------------------
|
| 293 |
+
# Amount validation (Screen 3 custom input)
|
| 294 |
+
# ---------------------------------------------------------------------------
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
@pytest.mark.parametrize(
|
| 302 |
+
"raw,err_substr",
|
| 303 |
+
[
|
| 304 |
+
("", "dollar amount"),
|
| 305 |
+
("0", "greater than"),
|
| 306 |
+
("-5", "greater than"),
|
| 307 |
+
("10.005", "cent"), # multipleOf 0.01 — sub-cent rejected
|
| 308 |
+
("5", "Minimum"), # below bounds.minUsd
|
| 309 |
+
("99999", "Maximum"), # above bounds.maxUsd
|
| 310 |
+
],
|
| 311 |
+
)
|
| 312 |
+
def test_validate_amount_rejections(raw, err_substr):
|
| 313 |
+
v = validate_charge_amount(raw, min_usd=Decimal("10"), max_usd=Decimal("10000"))
|
| 314 |
+
assert not v.ok
|
| 315 |
+
assert err_substr.lower() in (v.error or "").lower()
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# ---------------------------------------------------------------------------
|
| 319 |
+
# HERMES_DEV_BILLING_FIXTURE — offline card/scope state scaffolding (T0)
|
| 320 |
+
# ---------------------------------------------------------------------------
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
@pytest.mark.parametrize(
|
| 326 |
+
"name,has_card,is_admin,billing_on",
|
| 327 |
+
[
|
| 328 |
+
("nocard", False, True, True),
|
| 329 |
+
("card", True, True, True),
|
| 330 |
+
("card-autoreload", True, True, True),
|
| 331 |
+
("notadmin", True, False, True),
|
| 332 |
+
("billing-off", False, True, False),
|
| 333 |
+
],
|
| 334 |
+
)
|
| 335 |
+
def test_billing_fixture_card_and_gate_invariants(monkeypatch, name, has_card, is_admin, billing_on):
|
| 336 |
+
"""Each fixture state honors the card/admin/kill-switch contract the gate reads."""
|
| 337 |
+
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", name)
|
| 338 |
+
s = build_billing_state()
|
| 339 |
+
assert s.logged_in is True
|
| 340 |
+
assert (s.card is not None) is has_card
|
| 341 |
+
assert s.is_admin is is_admin
|
| 342 |
+
assert s.cli_billing_enabled is billing_on
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
|
tests/agent/test_bitwarden_secrets.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hermetic tests for the Bitwarden Secrets Manager integration.
|
| 2 |
+
|
| 3 |
+
We never hit GitHub or Bitwarden in tests — subprocess + urllib are
|
| 4 |
+
mocked so the suite stays fast and offline-safe. The "live" pull and
|
| 5 |
+
binary download are exercised manually by `hermes secrets bitwarden
|
| 6 |
+
setup` outside of pytest.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import io
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import stat
|
| 16 |
+
import subprocess
|
| 17 |
+
import sys
|
| 18 |
+
import time
|
| 19 |
+
import zipfile
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from unittest import mock
|
| 22 |
+
|
| 23 |
+
import pytest
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Make the worktree importable without depending on the installed wheel.
|
| 27 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 28 |
+
if str(ROOT) not in sys.path:
|
| 29 |
+
sys.path.insert(0, str(ROOT))
|
| 30 |
+
|
| 31 |
+
from agent.secret_sources import bitwarden as bw # noqa: E402
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.fixture(autouse=True)
|
| 35 |
+
def _reset_caches():
|
| 36 |
+
bw._reset_cache_for_tests()
|
| 37 |
+
yield
|
| 38 |
+
bw._reset_cache_for_tests()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@pytest.fixture
|
| 42 |
+
def hermes_home(tmp_path, monkeypatch):
|
| 43 |
+
"""Point Hermes at an isolated home directory."""
|
| 44 |
+
home = tmp_path / ".hermes"
|
| 45 |
+
home.mkdir()
|
| 46 |
+
monkeypatch.setenv("HERMES_HOME", str(home))
|
| 47 |
+
# Some modules cache get_hermes_home; clear if needed.
|
| 48 |
+
import hermes_constants
|
| 49 |
+
if hasattr(hermes_constants, "_HERMES_HOME_CACHE"):
|
| 50 |
+
hermes_constants._HERMES_HOME_CACHE = None # type: ignore[attr-defined]
|
| 51 |
+
return home
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
# _platform_asset_name
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@pytest.mark.parametrize(
|
| 60 |
+
"system,machine,libc_text,expected",
|
| 61 |
+
[
|
| 62 |
+
("Darwin", "x86_64", "",
|
| 63 |
+
f"bws-macos-universal-{bw._BWS_VERSION}.zip"),
|
| 64 |
+
("Darwin", "arm64", "",
|
| 65 |
+
f"bws-macos-universal-{bw._BWS_VERSION}.zip"),
|
| 66 |
+
("Linux", "x86_64", "glibc",
|
| 67 |
+
f"bws-x86_64-unknown-linux-gnu-{bw._BWS_VERSION}.zip"),
|
| 68 |
+
("Linux", "x86_64", "musl libc",
|
| 69 |
+
f"bws-x86_64-unknown-linux-musl-{bw._BWS_VERSION}.zip"),
|
| 70 |
+
("Linux", "aarch64", "",
|
| 71 |
+
f"bws-aarch64-unknown-linux-gnu-{bw._BWS_VERSION}.zip"),
|
| 72 |
+
("Windows", "AMD64", "",
|
| 73 |
+
f"bws-x86_64-pc-windows-msvc-{bw._BWS_VERSION}.zip"),
|
| 74 |
+
("Windows", "ARM64", "",
|
| 75 |
+
f"bws-aarch64-pc-windows-msvc-{bw._BWS_VERSION}.zip"),
|
| 76 |
+
],
|
| 77 |
+
)
|
| 78 |
+
def test_platform_asset_name(system, machine, libc_text, expected):
|
| 79 |
+
with mock.patch.object(bw.platform, "system", return_value=system), \
|
| 80 |
+
mock.patch.object(bw.platform, "machine", return_value=machine), \
|
| 81 |
+
mock.patch.object(
|
| 82 |
+
bw.subprocess,
|
| 83 |
+
"run",
|
| 84 |
+
return_value=mock.Mock(stdout=libc_text, stderr=libc_text),
|
| 85 |
+
):
|
| 86 |
+
assert bw._platform_asset_name() == expected
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
# install_bws — fully mocked HTTP
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _make_fake_zip(binary_bytes: bytes) -> bytes:
|
| 95 |
+
buf = io.BytesIO()
|
| 96 |
+
with zipfile.ZipFile(buf, "w") as zf:
|
| 97 |
+
zf.writestr("bws", binary_bytes)
|
| 98 |
+
return buf.getvalue()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
# _safe_extract_member — zip-slip containment
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@pytest.mark.parametrize(
|
| 109 |
+
"evil_name",
|
| 110 |
+
[
|
| 111 |
+
"../escape",
|
| 112 |
+
"../../escape",
|
| 113 |
+
"sub/../../escape",
|
| 114 |
+
],
|
| 115 |
+
)
|
| 116 |
+
def test_safe_extract_member_rejects_traversal(tmp_path, evil_name):
|
| 117 |
+
buf = io.BytesIO()
|
| 118 |
+
with zipfile.ZipFile(buf, "w") as zf:
|
| 119 |
+
zf.writestr(evil_name, b"pwned")
|
| 120 |
+
buf.seek(0)
|
| 121 |
+
|
| 122 |
+
dest = tmp_path / "extract"
|
| 123 |
+
dest.mkdir()
|
| 124 |
+
outside = tmp_path / "escape"
|
| 125 |
+
|
| 126 |
+
with zipfile.ZipFile(buf) as zf:
|
| 127 |
+
with pytest.raises(RuntimeError, match="unsafe archive member"):
|
| 128 |
+
bw._safe_extract_member(zf, evil_name, dest)
|
| 129 |
+
|
| 130 |
+
# The traversal target must not have been written.
|
| 131 |
+
assert not outside.exists()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_install_bws_happy_path(hermes_home, monkeypatch):
|
| 139 |
+
fake_binary = b"#!/bin/sh\necho 'bws fake 2.0.0'\n"
|
| 140 |
+
zip_bytes = _make_fake_zip(fake_binary)
|
| 141 |
+
asset_name = bw._platform_asset_name()
|
| 142 |
+
checksum_text = (
|
| 143 |
+
f"{hashlib.sha256(zip_bytes).hexdigest()} {asset_name}\n"
|
| 144 |
+
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff other-file\n"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
def fake_download(url, dest):
|
| 148 |
+
if url.endswith(".zip"):
|
| 149 |
+
Path(dest).write_bytes(zip_bytes)
|
| 150 |
+
elif url.endswith(".txt"):
|
| 151 |
+
Path(dest).write_text(checksum_text)
|
| 152 |
+
else:
|
| 153 |
+
raise AssertionError(f"unexpected download url: {url}")
|
| 154 |
+
|
| 155 |
+
monkeypatch.setattr(bw, "_http_download", fake_download)
|
| 156 |
+
|
| 157 |
+
path = bw.install_bws()
|
| 158 |
+
assert path.exists()
|
| 159 |
+
assert path.read_bytes() == fake_binary
|
| 160 |
+
# Executable bit set
|
| 161 |
+
assert path.stat().st_mode & stat.S_IXUSR
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
# fetch_bitwarden_secrets
|
| 170 |
+
# ---------------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _fake_bws_payload(items):
|
| 174 |
+
return json.dumps(items)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def test_fetch_server_url_sets_env(monkeypatch, tmp_path):
|
| 190 |
+
"""server_url must be plumbed into the subprocess as BWS_SERVER_URL."""
|
| 191 |
+
fake_binary = tmp_path / "bws"
|
| 192 |
+
fake_binary.write_text("")
|
| 193 |
+
payload = _fake_bws_payload([{"key": "K", "value": "v"}])
|
| 194 |
+
|
| 195 |
+
captured_env = {}
|
| 196 |
+
|
| 197 |
+
def fake_run(cmd, **kwargs):
|
| 198 |
+
captured_env.update(kwargs["env"])
|
| 199 |
+
return mock.Mock(returncode=0, stdout=payload, stderr="")
|
| 200 |
+
|
| 201 |
+
monkeypatch.setattr(bw.subprocess, "run", fake_run)
|
| 202 |
+
|
| 203 |
+
bw.fetch_bitwarden_secrets(
|
| 204 |
+
access_token="0.t",
|
| 205 |
+
project_id="p",
|
| 206 |
+
binary=fake_binary,
|
| 207 |
+
use_cache=False,
|
| 208 |
+
server_url="https://vault.bitwarden.eu",
|
| 209 |
+
)
|
| 210 |
+
assert captured_env.get("BWS_SERVER_URL") == "https://vault.bitwarden.eu"
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
# apply_bitwarden_secrets — the public entry point used by env_loader
|
| 221 |
+
# ---------------------------------------------------------------------------
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
# env_loader integration
|
| 240 |
+
# ---------------------------------------------------------------------------
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def test_env_loader_calls_bsm_when_enabled(tmp_path, monkeypatch):
|
| 246 |
+
home = tmp_path / ".hermes"
|
| 247 |
+
home.mkdir()
|
| 248 |
+
(home / "config.yaml").write_text(
|
| 249 |
+
"secrets:\n"
|
| 250 |
+
" bitwarden:\n"
|
| 251 |
+
" enabled: true\n"
|
| 252 |
+
" project_id: 'proj-1'\n"
|
| 253 |
+
" access_token_env: 'BWS_ACCESS_TOKEN'\n"
|
| 254 |
+
" cache_ttl_seconds: 0\n"
|
| 255 |
+
" override_existing: false\n"
|
| 256 |
+
" auto_install: false\n"
|
| 257 |
+
)
|
| 258 |
+
monkeypatch.setenv("HERMES_HOME", str(home))
|
| 259 |
+
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t")
|
| 260 |
+
monkeypatch.delenv("MY_BSM_KEY", raising=False)
|
| 261 |
+
|
| 262 |
+
called = {"n": 0}
|
| 263 |
+
|
| 264 |
+
def fake_fetch(**kwargs):
|
| 265 |
+
called["n"] += 1
|
| 266 |
+
assert kwargs["project_id"] == "proj-1"
|
| 267 |
+
return {"MY_BSM_KEY": "from-bsm"}, []
|
| 268 |
+
|
| 269 |
+
monkeypatch.setattr(
|
| 270 |
+
"agent.secret_sources.bitwarden.find_bws",
|
| 271 |
+
lambda **_kw: Path("/fake/bws"),
|
| 272 |
+
)
|
| 273 |
+
monkeypatch.setattr(
|
| 274 |
+
"agent.secret_sources.bitwarden.fetch_bitwarden_secrets",
|
| 275 |
+
fake_fetch,
|
| 276 |
+
)
|
| 277 |
+
from agent.secret_sources import registry as reg_module
|
| 278 |
+
|
| 279 |
+
reg_module._reset_registry_for_tests()
|
| 280 |
+
|
| 281 |
+
from hermes_cli.env_loader import _apply_external_secret_sources
|
| 282 |
+
_apply_external_secret_sources(home)
|
| 283 |
+
|
| 284 |
+
assert called["n"] == 1
|
| 285 |
+
assert os.environ.get("MY_BSM_KEY") == "from-bsm"
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# ---------------------------------------------------------------------------
|
| 289 |
+
# Disk-persisted cache (cross-process — speeds up back-to-back CLI invocations)
|
| 290 |
+
# ---------------------------------------------------------------------------
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def test_disk_cache_key_mismatch_triggers_refetch(monkeypatch, tmp_path):
|
| 300 |
+
"""Disk cache entry written by a different token/project is ignored."""
|
| 301 |
+
home = tmp_path / ".hermes"
|
| 302 |
+
home.mkdir()
|
| 303 |
+
fake_binary = tmp_path / "bws"
|
| 304 |
+
fake_binary.write_text("")
|
| 305 |
+
payload = _fake_bws_payload([{"key": "K1", "value": "v1"}])
|
| 306 |
+
|
| 307 |
+
call_count = {"n": 0}
|
| 308 |
+
def fake_run(*a, **kw):
|
| 309 |
+
call_count["n"] += 1
|
| 310 |
+
return mock.Mock(returncode=0, stdout=payload, stderr="")
|
| 311 |
+
monkeypatch.setattr(bw.subprocess, "run", fake_run)
|
| 312 |
+
bw._reset_cache_for_tests(home)
|
| 313 |
+
|
| 314 |
+
# Write a cache entry for a DIFFERENT token/project pair
|
| 315 |
+
cache_path = bw._disk_cache_path(home)
|
| 316 |
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
| 317 |
+
cache_path.write_text(json.dumps({
|
| 318 |
+
"key": "deadbeef00000000|other-project|",
|
| 319 |
+
"secrets": {"OTHER": "should-not-leak"},
|
| 320 |
+
"fetched_at": time.time(),
|
| 321 |
+
}))
|
| 322 |
+
|
| 323 |
+
secrets, _ = bw.fetch_bitwarden_secrets(
|
| 324 |
+
access_token="0.t", project_id="proj-1", binary=fake_binary,
|
| 325 |
+
cache_ttl_seconds=300, home_path=home,
|
| 326 |
+
)
|
| 327 |
+
# We must NOT have used the foreign cache entry
|
| 328 |
+
assert secrets == {"K1": "v1"}
|
| 329 |
+
assert "OTHER" not in secrets
|
| 330 |
+
assert call_count["n"] == 1
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def test_encrypted_cache_writes_without_plaintext(monkeypatch, tmp_path):
|
| 338 |
+
"""Encrypted cache stores last-good secrets without raw values on disk."""
|
| 339 |
+
home = tmp_path / ".hermes"
|
| 340 |
+
home.mkdir()
|
| 341 |
+
fake_binary = tmp_path / "bws"
|
| 342 |
+
fake_binary.write_text("")
|
| 343 |
+
payload = _fake_bws_payload([{"key": "K1", "value": "secret-value"}])
|
| 344 |
+
|
| 345 |
+
monkeypatch.setattr(
|
| 346 |
+
bw.subprocess,
|
| 347 |
+
"run",
|
| 348 |
+
lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""),
|
| 349 |
+
)
|
| 350 |
+
bw._reset_cache_for_tests(home)
|
| 351 |
+
# A successful encrypted write must remove a pre-existing legacy plaintext
|
| 352 |
+
# cache from the migration path.
|
| 353 |
+
legacy_key = (bw._token_fingerprint("0.t"), "proj-1", "")
|
| 354 |
+
bw._DISK_CACHE.write(
|
| 355 |
+
legacy_key,
|
| 356 |
+
bw._CachedFetch(secrets={"K1": "legacy"}, fetched_at=time.time()),
|
| 357 |
+
300,
|
| 358 |
+
home,
|
| 359 |
+
)
|
| 360 |
+
assert bw._disk_cache_path(home).exists()
|
| 361 |
+
|
| 362 |
+
secrets, warnings = bw.fetch_bitwarden_secrets(
|
| 363 |
+
access_token="0.t", project_id="proj-1", binary=fake_binary,
|
| 364 |
+
cache_ttl_seconds=0, encrypted_cache_enabled=True,
|
| 365 |
+
encrypted_cache_max_stale_seconds=604800, home_path=home,
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
assert secrets == {"K1": "secret-value"}
|
| 369 |
+
assert warnings == []
|
| 370 |
+
assert not bw._disk_cache_path(home).exists()
|
| 371 |
+
cache_path = bw._encrypted_disk_cache_path(home)
|
| 372 |
+
assert cache_path.exists()
|
| 373 |
+
mode = stat.S_IMODE(os.stat(cache_path).st_mode)
|
| 374 |
+
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
| 375 |
+
text = cache_path.read_text()
|
| 376 |
+
assert "secret-value" not in text
|
| 377 |
+
assert "0.t" not in text
|
| 378 |
+
payload_disk = json.loads(text)
|
| 379 |
+
assert set(payload_disk.keys()) == {
|
| 380 |
+
"version", "key", "salt", "nonce", "ciphertext",
|
| 381 |
+
}
|
| 382 |
+
assert not bw._disk_cache_path(home).exists()
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def test_encrypted_cache_falls_back_on_network_error(monkeypatch, tmp_path):
|
| 388 |
+
"""A fresh-enough encrypted cache is used when BWS is unreachable."""
|
| 389 |
+
home = tmp_path / ".hermes"
|
| 390 |
+
home.mkdir()
|
| 391 |
+
fake_binary = tmp_path / "bws"
|
| 392 |
+
fake_binary.write_text("")
|
| 393 |
+
calls = {"n": 0}
|
| 394 |
+
|
| 395 |
+
def fake_run(*a, **kw):
|
| 396 |
+
calls["n"] += 1
|
| 397 |
+
if calls["n"] == 1:
|
| 398 |
+
return mock.Mock(
|
| 399 |
+
returncode=0,
|
| 400 |
+
stdout=_fake_bws_payload([{"key": "K1", "value": "cached"}]),
|
| 401 |
+
stderr="",
|
| 402 |
+
)
|
| 403 |
+
return mock.Mock(
|
| 404 |
+
returncode=1,
|
| 405 |
+
stdout="",
|
| 406 |
+
stderr="Error: network is unreachable",
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
monkeypatch.setattr(bw.subprocess, "run", fake_run)
|
| 410 |
+
bw._reset_cache_for_tests(home)
|
| 411 |
+
|
| 412 |
+
first, _ = bw.fetch_bitwarden_secrets(
|
| 413 |
+
access_token="0.t", project_id="proj-1", binary=fake_binary,
|
| 414 |
+
cache_ttl_seconds=0, encrypted_cache_enabled=True,
|
| 415 |
+
encrypted_cache_max_stale_seconds=604800, home_path=home,
|
| 416 |
+
)
|
| 417 |
+
assert first == {"K1": "cached"}
|
| 418 |
+
bw._CACHE.clear()
|
| 419 |
+
|
| 420 |
+
second, warnings = bw.fetch_bitwarden_secrets(
|
| 421 |
+
access_token="0.t", project_id="proj-1", binary=fake_binary,
|
| 422 |
+
cache_ttl_seconds=0, encrypted_cache_enabled=True,
|
| 423 |
+
encrypted_cache_max_stale_seconds=604800, home_path=home,
|
| 424 |
+
)
|
| 425 |
+
assert second == {"K1": "cached"}
|
| 426 |
+
assert calls["n"] == 2
|
| 427 |
+
assert len(warnings) == 1
|
| 428 |
+
assert "stale ENCRYPTED disk cache" in warnings[0]
|
| 429 |
+
assert "bws live fetch failed" in warnings[0]
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
# ---------------------------------------------------------------------------
|
| 437 |
+
# Stale disk cache fallback when live bws fetch fails
|
| 438 |
+
# ---------------------------------------------------------------------------
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _seed_stale_disk_cache(home, *, secrets, age_seconds, project_id="proj-1",
|
| 442 |
+
access_token="0.t", server_url=""):
|
| 443 |
+
"""Populate the disk cache as if a successful fetch happened `age_seconds`
|
| 444 |
+
ago. Writes the JSON payload directly (same shape the shared DiskCache
|
| 445 |
+
reads/writes) rather than going through DiskCache.write, since that
|
| 446 |
+
would honor cache_ttl_seconds and refuse to persist an already-"stale"
|
| 447 |
+
entry — this needs to land on disk regardless of TTL."""
|
| 448 |
+
cache_key = (
|
| 449 |
+
bw._token_fingerprint(access_token), project_id, server_url,
|
| 450 |
+
)
|
| 451 |
+
cache_path = bw._disk_cache_path(home)
|
| 452 |
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
| 453 |
+
cache_path.write_text(json.dumps({
|
| 454 |
+
"key": bw._cache_key_str(cache_key),
|
| 455 |
+
"secrets": secrets,
|
| 456 |
+
"fetched_at": time.time() - age_seconds,
|
| 457 |
+
}))
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def test_stale_disk_cache_returned_when_bws_fails(monkeypatch, tmp_path):
|
| 461 |
+
"""When bws fails and the disk cache is stale, return the stale secrets
|
| 462 |
+
with a warning rather than raising."""
|
| 463 |
+
home = tmp_path / ".hermes"
|
| 464 |
+
home.mkdir()
|
| 465 |
+
fake_binary = tmp_path / "bws"
|
| 466 |
+
fake_binary.write_text("")
|
| 467 |
+
bw._reset_cache_for_tests(home)
|
| 468 |
+
|
| 469 |
+
# Seed a stale (older than TTL) disk cache from a previous successful fetch
|
| 470 |
+
_seed_stale_disk_cache(home, secrets={"OPENAI_API_KEY": "sk-old"},
|
| 471 |
+
age_seconds=3600)
|
| 472 |
+
|
| 473 |
+
# Now simulate a BWS network failure
|
| 474 |
+
def fail_run(*a, **kw):
|
| 475 |
+
return mock.Mock(returncode=1, stdout="",
|
| 476 |
+
stderr="Error: dns resolution failed")
|
| 477 |
+
monkeypatch.setattr(bw.subprocess, "run", fail_run)
|
| 478 |
+
|
| 479 |
+
secrets, warnings = bw.fetch_bitwarden_secrets(
|
| 480 |
+
access_token="0.t", project_id="proj-1", binary=fake_binary,
|
| 481 |
+
cache_ttl_seconds=300, home_path=home,
|
| 482 |
+
)
|
| 483 |
+
assert secrets == {"OPENAI_API_KEY": "sk-old"}
|
| 484 |
+
assert len(warnings) == 1
|
| 485 |
+
assert "stale disk cache" in warnings[0]
|
| 486 |
+
assert "dns resolution failed" in warnings[0]
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def test_stale_fallback_skipped_on_auth_failure(monkeypatch, tmp_path):
|
| 498 |
+
"""An AUTH_FAILED bws error must raise, not serve stale secrets — a bad
|
| 499 |
+
access token indicates a real credential problem the caller needs to
|
| 500 |
+
see, not a transient outage worth papering over."""
|
| 501 |
+
home = tmp_path / ".hermes"
|
| 502 |
+
home.mkdir()
|
| 503 |
+
fake_binary = tmp_path / "bws"
|
| 504 |
+
fake_binary.write_text("")
|
| 505 |
+
bw._reset_cache_for_tests(home)
|
| 506 |
+
|
| 507 |
+
_seed_stale_disk_cache(home, secrets={"K1": "v1"}, age_seconds=3600)
|
| 508 |
+
|
| 509 |
+
monkeypatch.setattr(
|
| 510 |
+
bw.subprocess, "run",
|
| 511 |
+
lambda *a, **kw: mock.Mock(returncode=1, stdout="",
|
| 512 |
+
stderr="Error: unauthorized (401)"),
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
with pytest.raises(RuntimeError, match="unauthorized"):
|
| 516 |
+
bw.fetch_bitwarden_secrets(
|
| 517 |
+
access_token="0.t", project_id="proj-1", binary=fake_binary,
|
| 518 |
+
cache_ttl_seconds=300, home_path=home,
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
|
tests/agent/test_bounded_response.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for bounded reads of streaming HTTP error response bodies.
|
| 2 |
+
|
| 3 |
+
Exercises the real ``httpx`` streaming path against an in-process socket server
|
| 4 |
+
(no mocks) so the byte-cap and hard-deadline contracts are validated end to end,
|
| 5 |
+
the way they behave against a real misbehaving provider.
|
| 6 |
+
|
| 7 |
+
Covers the bug class ported from openclaw/openclaw#95108: an unbounded
|
| 8 |
+
``response.read()`` on a non-OK streaming response can balloon memory (huge
|
| 9 |
+
body) or hang forever (body opens then stalls).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import http.server
|
| 15 |
+
import json
|
| 16 |
+
import socketserver
|
| 17 |
+
import threading
|
| 18 |
+
import time
|
| 19 |
+
|
| 20 |
+
import httpx
|
| 21 |
+
import pytest
|
| 22 |
+
|
| 23 |
+
from agent.bounded_response import read_streaming_error_body
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class _ThreadingServer(socketserver.ThreadingTCPServer):
|
| 27 |
+
daemon_threads = True
|
| 28 |
+
allow_reuse_address = True
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _make_handler():
|
| 32 |
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
| 33 |
+
def log_message(self, format, *args): # noqa: A002 - http.server API
|
| 34 |
+
pass
|
| 35 |
+
|
| 36 |
+
def do_POST(self): # noqa: N802 - http.server API
|
| 37 |
+
if self.path == "/oversize":
|
| 38 |
+
# ~128 MiB if read unbounded; no Content-Length.
|
| 39 |
+
self.send_response(500)
|
| 40 |
+
self.send_header("Content-Type", "text/plain")
|
| 41 |
+
self.end_headers()
|
| 42 |
+
try:
|
| 43 |
+
for _ in range(2000):
|
| 44 |
+
self.wfile.write(b"x" * 65536)
|
| 45 |
+
self.wfile.flush()
|
| 46 |
+
except Exception:
|
| 47 |
+
pass
|
| 48 |
+
elif self.path == "/stall":
|
| 49 |
+
# Send a little, then stall forever (no further bytes).
|
| 50 |
+
self.send_response(500)
|
| 51 |
+
self.send_header("Content-Type", "text/plain")
|
| 52 |
+
self.end_headers()
|
| 53 |
+
self.wfile.write(b"partial failure detail")
|
| 54 |
+
self.wfile.flush()
|
| 55 |
+
time.sleep(60)
|
| 56 |
+
elif self.path == "/normal":
|
| 57 |
+
body = json.dumps(
|
| 58 |
+
{
|
| 59 |
+
"error": {
|
| 60 |
+
"code": 429,
|
| 61 |
+
"message": "quota exceeded",
|
| 62 |
+
"status": "RESOURCE_EXHAUSTED",
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
).encode()
|
| 66 |
+
self.send_response(429)
|
| 67 |
+
self.send_header("Content-Type", "application/json")
|
| 68 |
+
self.send_header("Content-Length", str(len(body)))
|
| 69 |
+
self.end_headers()
|
| 70 |
+
self.wfile.write(body)
|
| 71 |
+
elif self.path == "/empty":
|
| 72 |
+
self.send_response(500)
|
| 73 |
+
self.send_header("Content-Length", "0")
|
| 74 |
+
self.end_headers()
|
| 75 |
+
|
| 76 |
+
return _Handler
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@pytest.fixture()
|
| 80 |
+
def server_base():
|
| 81 |
+
httpd = _ThreadingServer(("127.0.0.1", 0), _make_handler())
|
| 82 |
+
port = httpd.server_address[1]
|
| 83 |
+
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
| 84 |
+
thread.start()
|
| 85 |
+
try:
|
| 86 |
+
yield f"http://127.0.0.1:{port}"
|
| 87 |
+
finally:
|
| 88 |
+
httpd.shutdown()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@pytest.fixture()
|
| 92 |
+
def client():
|
| 93 |
+
# Generous read timeout so the bounding is provably done by our helper,
|
| 94 |
+
# not by httpx's own timeout.
|
| 95 |
+
c = httpx.Client(
|
| 96 |
+
timeout=httpx.Timeout(connect=5.0, read=45.0, write=5.0, pool=5.0)
|
| 97 |
+
)
|
| 98 |
+
try:
|
| 99 |
+
yield c
|
| 100 |
+
finally:
|
| 101 |
+
c.close()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_oversize_body_is_capped(server_base, client):
|
| 105 |
+
start = time.monotonic()
|
| 106 |
+
with client.stream("POST", server_base + "/oversize") as response:
|
| 107 |
+
text = read_streaming_error_body(
|
| 108 |
+
response, max_bytes=64 * 1024, timeout_s=10.0
|
| 109 |
+
)
|
| 110 |
+
elapsed = time.monotonic() - start
|
| 111 |
+
assert 0 < len(text) <= 64 * 1024
|
| 112 |
+
# Capping must return promptly, not after draining the whole body.
|
| 113 |
+
assert elapsed < 9.0
|
tests/agent/test_budget_reasoning_details_exclusion.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for #73298 (second site): the tail-budget walk must not
|
| 2 |
+
charge the signed/base64 ``reasoning_details`` envelope at chars/4.
|
| 3 |
+
|
| 4 |
+
The preflight estimator (``model_metadata._estimate_message_tokens_without_images``)
|
| 5 |
+
excludes ``reasoning_details`` entirely; ``_estimate_msg_budget_tokens`` in the
|
| 6 |
+
compressor mirrors that for the envelope while — per the #51800 counter-argument —
|
| 7 |
+
still counting actual thinking TEXT when it is not already carried by
|
| 8 |
+
``reasoning``/``reasoning_content``.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from agent.context_compressor import (
|
| 12 |
+
_estimate_msg_budget_tokens,
|
| 13 |
+
_reasoning_details_text_chars,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _signed_thinking_block(thinking_chars: int, sig_chars: int) -> dict:
|
| 18 |
+
return {
|
| 19 |
+
"type": "thinking",
|
| 20 |
+
"thinking": "t" * thinking_chars,
|
| 21 |
+
"signature": "s" * sig_chars,
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TestBudgetExcludesReasoningDetailsEnvelope:
|
| 26 |
+
def test_signature_blob_not_charged(self):
|
| 27 |
+
base = {"role": "assistant", "content": "hello there"}
|
| 28 |
+
with_envelope = dict(
|
| 29 |
+
base,
|
| 30 |
+
reasoning_details=[_signed_thinking_block(0, 40_000)],
|
| 31 |
+
)
|
| 32 |
+
plain = _estimate_msg_budget_tokens(base)
|
| 33 |
+
loaded = _estimate_msg_budget_tokens(with_envelope)
|
| 34 |
+
# 40K chars of base64 signature would add ~10K phantom tokens at
|
| 35 |
+
# chars/4. The envelope must be invisible to the budget.
|
| 36 |
+
assert loaded - plain < 50, (
|
| 37 |
+
f"signed envelope inflated the budget by {loaded - plain} tokens"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def test_thinking_text_still_counted(self):
|
| 41 |
+
"""#51800: real reasoning text must stay visible to the budget when
|
| 42 |
+
it is not duplicated into reasoning/reasoning_content."""
|
| 43 |
+
base = {"role": "assistant", "content": "hello there"}
|
| 44 |
+
with_text = dict(
|
| 45 |
+
base,
|
| 46 |
+
reasoning_details=[_signed_thinking_block(8_000, 0)],
|
| 47 |
+
)
|
| 48 |
+
plain = _estimate_msg_budget_tokens(base)
|
| 49 |
+
loaded = _estimate_msg_budget_tokens(with_text)
|
| 50 |
+
# 8K chars of thinking ≈ 2K tokens at chars/4.
|
| 51 |
+
assert loaded - plain > 1_000, (
|
| 52 |
+
"thinking TEXT inside reasoning_details was dropped from the "
|
| 53 |
+
f"budget (delta {loaded - plain}); only the envelope may be excluded"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
def test_duplicated_thinking_text_not_double_charged(self):
|
| 57 |
+
"""Anthropic-wire sessions carry the prose byte-identical in both
|
| 58 |
+
reasoning_content and reasoning_details; charge it once."""
|
| 59 |
+
prose = "deep thought " * 1_000
|
| 60 |
+
msg = {
|
| 61 |
+
"role": "assistant",
|
| 62 |
+
"content": "hi",
|
| 63 |
+
"reasoning_content": prose,
|
| 64 |
+
"reasoning_details": [
|
| 65 |
+
{"type": "thinking", "thinking": prose, "signature": "s" * 5_000},
|
| 66 |
+
],
|
| 67 |
+
}
|
| 68 |
+
once = _estimate_msg_budget_tokens(
|
| 69 |
+
{"role": "assistant", "content": "hi", "reasoning_content": prose}
|
| 70 |
+
)
|
| 71 |
+
both = _estimate_msg_budget_tokens(msg)
|
| 72 |
+
assert both - once < 100, (
|
| 73 |
+
f"thinking prose charged twice (delta {both - once} tokens)"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
def test_codex_encrypted_content_is_opaque_to_local_estimates(self):
|
| 77 |
+
"""Ciphertext is priced by the provider's own count, never by its bytes (a native
|
| 78 |
+
compaction checkpoint alone is ~5M chars, #100611): the budget walk and the trigger
|
| 79 |
+
estimator both charge it ~0, so only real usage ever prices it (#104462)."""
|
| 80 |
+
from agent.model_metadata import estimate_messages_tokens_rough
|
| 81 |
+
|
| 82 |
+
base = {"role": "assistant", "content": "hi"}
|
| 83 |
+
loaded = dict(base, codex_reasoning_items=[{"type": "reasoning", "encrypted_content": "e" * 20_000}])
|
| 84 |
+
assert _estimate_msg_budget_tokens(loaded) - _estimate_msg_budget_tokens(base) < 50
|
| 85 |
+
assert estimate_messages_tokens_rough([loaded]) - estimate_messages_tokens_rough([base]) < 50
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TestReasoningDetailsTextChars:
|
| 89 |
+
def test_none_and_empty(self):
|
| 90 |
+
assert _reasoning_details_text_chars(None) == 0
|
| 91 |
+
assert _reasoning_details_text_chars([]) == 0
|
| 92 |
+
assert _reasoning_details_text_chars("") == 0
|
| 93 |
+
|
| 94 |
+
def test_string_counts_fully(self):
|
| 95 |
+
assert _reasoning_details_text_chars("abcd" * 10) == 40
|
| 96 |
+
|
| 97 |
+
def test_envelope_keys_skipped(self):
|
| 98 |
+
blocks = [
|
| 99 |
+
{"type": "thinking", "thinking": "abc", "signature": "S" * 999},
|
| 100 |
+
{"type": "redacted_thinking", "data": "D" * 999},
|
| 101 |
+
{"type": "text", "text": "xy"},
|
| 102 |
+
]
|
| 103 |
+
assert _reasoning_details_text_chars(blocks) == 5
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class TestPreflightExcludesReasoningDetails:
|
| 107 |
+
"""Regression coverage for the preflight half (#73306 shipped without a
|
| 108 |
+
test): _estimate_message_tokens_without_images must not count the
|
| 109 |
+
reasoning_details envelope."""
|
| 110 |
+
|
| 111 |
+
def test_preflight_estimate_ignores_reasoning_details(self):
|
| 112 |
+
from agent.model_metadata import _estimate_message_tokens_without_images
|
| 113 |
+
|
| 114 |
+
base = {"role": "assistant", "content": "hello there"}
|
| 115 |
+
loaded = dict(
|
| 116 |
+
base,
|
| 117 |
+
reasoning_details=[_signed_thinking_block(10_000, 40_000)],
|
| 118 |
+
)
|
| 119 |
+
plain = _estimate_message_tokens_without_images(base)
|
| 120 |
+
with_details = _estimate_message_tokens_without_images(loaded)
|
| 121 |
+
assert with_details - plain < 50, (
|
| 122 |
+
"preflight estimator counted the reasoning_details payload "
|
| 123 |
+
f"(delta {with_details - plain} tokens) — compression would fire "
|
| 124 |
+
"at a fraction of real usage on thinking models (#73298)"
|
| 125 |
+
)
|
tests/agent/test_builtin_memory_disabled_surface.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Built-in memory disabled in config must leave no dead surface behind.
|
| 2 |
+
|
| 3 |
+
Setting ``memory.memory_enabled: false`` and ``memory.user_profile_enabled:
|
| 4 |
+
false`` stops ``agent_init`` from building a ``MemoryStore``, so the ``memory``
|
| 5 |
+
tool dispatches against ``store=None`` and every call comes back "Memory is not
|
| 6 |
+
available". Before the fix the tool stayed in the schema and MEMORY_GUIDANCE
|
| 7 |
+
stayed in the system prompt, so users running a third-party provider (Hindsight,
|
| 8 |
+
Mem0, …) paid for both on every API call with no way to drop them — listing
|
| 9 |
+
``memory`` under ``disabled_toolsets`` takes the provider's tools down too.
|
| 10 |
+
|
| 11 |
+
These tests exercise the real resolution chain (config on disk → check_fn →
|
| 12 |
+
``get_tool_definitions``) against a temp ``HERMES_HOME``, not mocks.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
from unittest.mock import patch
|
| 17 |
+
|
| 18 |
+
import pytest
|
| 19 |
+
import yaml
|
| 20 |
+
|
| 21 |
+
from model_tools import get_tool_definitions
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.fixture(autouse=True)
|
| 25 |
+
def _clear_caches():
|
| 26 |
+
"""check_fn results and tool definitions are both cached; config written by
|
| 27 |
+
a test only takes effect once those are dropped."""
|
| 28 |
+
from model_tools import _clear_tool_defs_cache
|
| 29 |
+
from tools.registry import invalidate_check_fn_cache
|
| 30 |
+
|
| 31 |
+
invalidate_check_fn_cache()
|
| 32 |
+
_clear_tool_defs_cache()
|
| 33 |
+
yield
|
| 34 |
+
invalidate_check_fn_cache()
|
| 35 |
+
_clear_tool_defs_cache()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _write_memory_config(home, **memory_section):
|
| 39 |
+
home.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
(home / "config.yaml").write_text(
|
| 41 |
+
yaml.safe_dump({"memory": memory_section}), encoding="utf-8"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.fixture
|
| 46 |
+
def hermes_home(tmp_path, monkeypatch):
|
| 47 |
+
home = tmp_path / ".hermes"
|
| 48 |
+
monkeypatch.setenv("HERMES_HOME", str(home))
|
| 49 |
+
return home
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _memory_tool_definition():
|
| 53 |
+
return next(
|
| 54 |
+
tool["function"]
|
| 55 |
+
for tool in get_tool_definitions(enabled_toolsets=["memory"], quiet_mode=True)
|
| 56 |
+
if tool["function"]["name"] == "memory"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _memory_tool_names():
|
| 61 |
+
tools = get_tool_definitions(enabled_toolsets=["memory"], quiet_mode=True)
|
| 62 |
+
return {tool["function"]["name"] for tool in tools}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TestBuiltinMemoryToolAvailability:
|
| 66 |
+
def test_tool_hidden_when_both_stores_disabled(self, hermes_home):
|
| 67 |
+
_write_memory_config(
|
| 68 |
+
hermes_home, memory_enabled=False, user_profile_enabled=False
|
| 69 |
+
)
|
| 70 |
+
assert "memory" not in _memory_tool_names()
|
| 71 |
+
|
| 72 |
+
def test_tool_present_when_only_user_profile_enabled(self, hermes_home):
|
| 73 |
+
_write_memory_config(
|
| 74 |
+
hermes_home, memory_enabled=False, user_profile_enabled=True
|
| 75 |
+
)
|
| 76 |
+
definition = _memory_tool_definition()
|
| 77 |
+
assert definition["parameters"]["properties"]["target"]["enum"] == ["user"]
|
| 78 |
+
assert "only 'user' is enabled" in definition["description"]
|
| 79 |
+
assert "only 'memory' is enabled" not in definition["description"]
|
| 80 |
+
|
| 81 |
+
def test_tool_present_when_only_memory_enabled(self, hermes_home):
|
| 82 |
+
_write_memory_config(
|
| 83 |
+
hermes_home, memory_enabled=True, user_profile_enabled=False
|
| 84 |
+
)
|
| 85 |
+
definition = _memory_tool_definition()
|
| 86 |
+
assert definition["parameters"]["properties"]["target"]["enum"] == ["memory"]
|
| 87 |
+
assert "only 'memory' is enabled" in definition["description"]
|
| 88 |
+
assert "only 'user' is enabled" not in definition["description"]
|
| 89 |
+
|
| 90 |
+
def test_tool_present_by_default(self, hermes_home):
|
| 91 |
+
"""No config file at all must not strip a working tool."""
|
| 92 |
+
assert "memory" in _memory_tool_names()
|
| 93 |
+
|
| 94 |
+
def test_missing_memory_flags_fail_open_consistently(self):
|
| 95 |
+
from tools.memory_tool import get_builtin_memory_store_flags
|
| 96 |
+
|
| 97 |
+
assert get_builtin_memory_store_flags({"memory": {}}) == (True, True)
|
| 98 |
+
assert get_builtin_memory_store_flags({}) == (True, True)
|
| 99 |
+
|
| 100 |
+
def test_quoted_false_flags_are_disabled(self):
|
| 101 |
+
from tools.memory_tool import get_builtin_memory_store_flags
|
| 102 |
+
|
| 103 |
+
assert get_builtin_memory_store_flags(
|
| 104 |
+
{"memory": {"memory_enabled": "false", "user_profile_enabled": "false"}}
|
| 105 |
+
) == (False, False)
|
| 106 |
+
|
| 107 |
+
def test_schema_reuses_availability_flag_snapshot(self, monkeypatch):
|
| 108 |
+
"""One definition pass must not reread config between check and schema."""
|
| 109 |
+
from tools import memory_tool as memory_tool_module
|
| 110 |
+
|
| 111 |
+
calls = 0
|
| 112 |
+
|
| 113 |
+
def _flags():
|
| 114 |
+
nonlocal calls
|
| 115 |
+
calls += 1
|
| 116 |
+
return (False, True) if calls == 1 else (True, False)
|
| 117 |
+
|
| 118 |
+
monkeypatch.setattr(memory_tool_module, "get_builtin_memory_store_flags", _flags)
|
| 119 |
+
definition = _memory_tool_definition()
|
| 120 |
+
|
| 121 |
+
assert calls == 1
|
| 122 |
+
assert definition["parameters"]["properties"]["target"]["enum"] == ["user"]
|
| 123 |
+
|
| 124 |
+
def test_unavailable_snapshot_cannot_survive_failed_recheck(self, monkeypatch):
|
| 125 |
+
from tools import memory_tool as memory_tool_module
|
| 126 |
+
|
| 127 |
+
monkeypatch.setattr(
|
| 128 |
+
memory_tool_module,
|
| 129 |
+
"get_builtin_memory_store_flags",
|
| 130 |
+
lambda: (False, False),
|
| 131 |
+
)
|
| 132 |
+
assert memory_tool_module.check_memory_requirements() is False
|
| 133 |
+
|
| 134 |
+
def _boom():
|
| 135 |
+
raise RuntimeError("config read failed")
|
| 136 |
+
|
| 137 |
+
monkeypatch.setattr(memory_tool_module, "get_builtin_memory_store_flags", _boom)
|
| 138 |
+
with pytest.raises(RuntimeError, match="config read failed"):
|
| 139 |
+
memory_tool_module.check_memory_requirements()
|
| 140 |
+
|
| 141 |
+
assert memory_tool_module._memory_surface_flags.get() is None
|
| 142 |
+
|
| 143 |
+
def test_config_flip_updates_tool_without_manual_cache_clear(self, hermes_home):
|
| 144 |
+
_write_memory_config(
|
| 145 |
+
hermes_home, memory_enabled=False, user_profile_enabled=False
|
| 146 |
+
)
|
| 147 |
+
assert "memory" not in _memory_tool_names()
|
| 148 |
+
|
| 149 |
+
_write_memory_config(
|
| 150 |
+
hermes_home, memory_enabled=True, user_profile_enabled=False
|
| 151 |
+
)
|
| 152 |
+
assert "memory" in _memory_tool_names()
|
| 153 |
+
|
| 154 |
+
def test_unreadable_config_fails_open(self, hermes_home, monkeypatch):
|
| 155 |
+
"""A config read error must not silently remove the tool."""
|
| 156 |
+
from tools import memory_tool as memory_tool_module
|
| 157 |
+
|
| 158 |
+
def _boom():
|
| 159 |
+
raise RuntimeError("config unreadable")
|
| 160 |
+
|
| 161 |
+
monkeypatch.setattr(
|
| 162 |
+
"hermes_cli.config.load_config_readonly", _boom, raising=False
|
| 163 |
+
)
|
| 164 |
+
assert memory_tool_module.check_memory_requirements() is True
|
| 165 |
+
|
| 166 |
+
def test_malformed_memory_section_normalizes_to_defaults(self):
|
| 167 |
+
from tools.memory_tool import (
|
| 168 |
+
get_builtin_memory_config,
|
| 169 |
+
get_builtin_memory_store_flags,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
config = {"memory": "not-a-mapping"}
|
| 173 |
+
assert get_builtin_memory_config(config) == {}
|
| 174 |
+
assert get_builtin_memory_store_flags(config) == (True, True)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class TestIndependentStoreWriteGates:
|
| 178 |
+
def _store(self, *, memory_enabled, user_profile_enabled):
|
| 179 |
+
from tools.memory_tool import MemoryStore
|
| 180 |
+
|
| 181 |
+
store = MemoryStore(
|
| 182 |
+
memory_char_limit=500,
|
| 183 |
+
user_char_limit=500,
|
| 184 |
+
memory_enabled=memory_enabled,
|
| 185 |
+
user_profile_enabled=user_profile_enabled,
|
| 186 |
+
)
|
| 187 |
+
store.load_from_disk()
|
| 188 |
+
return store
|
| 189 |
+
|
| 190 |
+
def test_user_profile_only_rejects_memory_write(self, hermes_home):
|
| 191 |
+
from tools.memory_tool import memory_tool
|
| 192 |
+
|
| 193 |
+
store = self._store(memory_enabled=False, user_profile_enabled=True)
|
| 194 |
+
denied = json.loads(memory_tool(action="add", target="memory", content="fact", store=store))
|
| 195 |
+
allowed = json.loads(memory_tool(action="add", target="user", content="pref", store=store))
|
| 196 |
+
|
| 197 |
+
assert denied["success"] is False
|
| 198 |
+
assert denied["target"] == "memory"
|
| 199 |
+
assert allowed["success"] is True
|
| 200 |
+
|
| 201 |
+
def test_memory_only_rejects_user_profile_write(self, hermes_home):
|
| 202 |
+
from tools.memory_tool import memory_tool
|
| 203 |
+
|
| 204 |
+
store = self._store(memory_enabled=True, user_profile_enabled=False)
|
| 205 |
+
denied = json.loads(memory_tool(action="add", target="user", content="pref", store=store))
|
| 206 |
+
allowed = json.loads(memory_tool(action="add", target="memory", content="fact", store=store))
|
| 207 |
+
|
| 208 |
+
assert denied["success"] is False
|
| 209 |
+
assert denied["target"] == "user"
|
| 210 |
+
assert allowed["success"] is True
|
| 211 |
+
|
| 212 |
+
def test_staged_write_cannot_bypass_target_gate(self, hermes_home):
|
| 213 |
+
from tools.memory_tool import apply_memory_pending
|
| 214 |
+
|
| 215 |
+
store = self._store(memory_enabled=False, user_profile_enabled=True)
|
| 216 |
+
result = apply_memory_pending(
|
| 217 |
+
{"action": "add", "target": "memory", "content": "fact"}, store
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
assert result["success"] is False
|
| 221 |
+
assert result["target"] == "memory"
|
| 222 |
+
|
| 223 |
+
def test_invalid_target_error_is_bounded_and_carries_recovery_hint(self, hermes_home):
|
| 224 |
+
"""Model-supplied target is interpolated into the error: it must stay
|
| 225 |
+
capped at the shared tool_error bound and keep the recovery hint."""
|
| 226 |
+
from tools.memory_tool import memory_tool
|
| 227 |
+
from tools.registry import _MAX_TOOL_ERROR_CHARS
|
| 228 |
+
|
| 229 |
+
store = self._store(memory_enabled=True, user_profile_enabled=True)
|
| 230 |
+
result = json.loads(
|
| 231 |
+
memory_tool(action="add", target="x" * 10_000, content="fact", store=store)
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
assert result["success"] is False
|
| 235 |
+
assert len(result["error"]) <= _MAX_TOOL_ERROR_CHARS + 32
|
| 236 |
+
|
| 237 |
+
short = json.loads(
|
| 238 |
+
memory_tool(action="add", target="bogus", content="fact", store=store)
|
| 239 |
+
)
|
| 240 |
+
assert short["success"] is False
|
| 241 |
+
assert "Use 'memory' or 'user'" in short["error"]
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
class TestExternalProviderSurvivesBuiltinDisable:
|
| 245 |
+
"""Dropping the built-in tool must not drop the external provider's tools.
|
| 246 |
+
|
| 247 |
+
``memory_provider_tools_enabled`` short-circuits on the built-in tool being
|
| 248 |
+
present, so hiding that tool moves the decision onto the toolset gate. The
|
| 249 |
+
provider must still be reachable for every way a caller can ask for memory.
|
| 250 |
+
"""
|
| 251 |
+
|
| 252 |
+
def test_provider_tools_enabled_when_memory_toolset_requested(self):
|
| 253 |
+
from agent.memory_manager import memory_provider_tools_enabled
|
| 254 |
+
|
| 255 |
+
assert memory_provider_tools_enabled(
|
| 256 |
+
["memory", "file"], None, memory_tool_present=False
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
def test_provider_tools_enabled_for_unrestricted_toolsets(self):
|
| 260 |
+
from agent.memory_manager import memory_provider_tools_enabled
|
| 261 |
+
|
| 262 |
+
assert memory_provider_tools_enabled(None, None, memory_tool_present=False)
|
| 263 |
+
|
| 264 |
+
def test_disabled_toolsets_still_takes_everything_down(self):
|
| 265 |
+
"""The heavy switch keeps its documented meaning."""
|
| 266 |
+
from agent.memory_manager import memory_provider_tools_enabled
|
| 267 |
+
|
| 268 |
+
assert not memory_provider_tools_enabled(
|
| 269 |
+
None, ["memory"], memory_tool_present=False
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
class TestInjectionEndToEnd:
|
| 274 |
+
"""The real ``inject_memory_provider_tools`` with no built-in memory tool."""
|
| 275 |
+
|
| 276 |
+
def test_provider_tools_injected_without_builtin_memory_tool(self):
|
| 277 |
+
from types import SimpleNamespace
|
| 278 |
+
|
| 279 |
+
from agent.memory_manager import MemoryManager, inject_memory_provider_tools
|
| 280 |
+
from agent.memory_provider import MemoryProvider
|
| 281 |
+
|
| 282 |
+
class _Provider(MemoryProvider):
|
| 283 |
+
@property
|
| 284 |
+
def name(self):
|
| 285 |
+
return "fake_hindsight"
|
| 286 |
+
|
| 287 |
+
def is_available(self):
|
| 288 |
+
return True
|
| 289 |
+
|
| 290 |
+
def initialize(self, session_id, **kwargs):
|
| 291 |
+
pass
|
| 292 |
+
|
| 293 |
+
def get_tool_schemas(self):
|
| 294 |
+
return [
|
| 295 |
+
{
|
| 296 |
+
"name": "hindsight_retain",
|
| 297 |
+
"description": "retain",
|
| 298 |
+
"parameters": {"type": "object", "properties": {}},
|
| 299 |
+
}
|
| 300 |
+
]
|
| 301 |
+
|
| 302 |
+
manager = MemoryManager()
|
| 303 |
+
manager.add_provider(_Provider())
|
| 304 |
+
agent = SimpleNamespace(
|
| 305 |
+
_memory_manager=manager,
|
| 306 |
+
enabled_toolsets=["memory"],
|
| 307 |
+
disabled_toolsets=None,
|
| 308 |
+
tools=[],
|
| 309 |
+
valid_tool_names=set(),
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
added = inject_memory_provider_tools(agent)
|
| 313 |
+
|
| 314 |
+
assert added == 1
|
| 315 |
+
assert "hindsight_retain" in agent.valid_tool_names
|
tests/agent/test_cache_disabled_on_stubs.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression for #76085: prompt_caching.cache_ttl off on stub policy paths.
|
| 2 |
+
|
| 3 |
+
Blank SimpleNamespace stubs used by MoA decoration and auxiliary/MoA
|
| 4 |
+
plan_cache_sections_for_destination never carried ``_cache_disabled``, so
|
| 5 |
+
``anthropic_prompt_cache_policy`` re-enabled cache_control markers even when
|
| 6 |
+
the operator set ``prompt_caching.cache_ttl: false``.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from types import SimpleNamespace
|
| 12 |
+
from unittest.mock import patch
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _has_cache_control(obj) -> bool:
|
| 16 |
+
if isinstance(obj, dict):
|
| 17 |
+
if "cache_control" in obj:
|
| 18 |
+
return True
|
| 19 |
+
return any(_has_cache_control(v) for v in obj.values())
|
| 20 |
+
if isinstance(obj, list):
|
| 21 |
+
return any(_has_cache_control(v) for v in obj)
|
| 22 |
+
return False
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TestPromptCachingDisabledFromConfig:
|
| 26 |
+
def test_off_values(self):
|
| 27 |
+
from agent.agent_runtime_helpers import prompt_caching_disabled_from_config
|
| 28 |
+
|
| 29 |
+
for ttl in (False, None, "off", "false", "disabled", "no", "none", "OFF"):
|
| 30 |
+
with patch(
|
| 31 |
+
"hermes_cli.config.load_config_readonly",
|
| 32 |
+
return_value={"prompt_caching": {"cache_ttl": ttl}},
|
| 33 |
+
):
|
| 34 |
+
assert prompt_caching_disabled_from_config() is True, ttl
|
| 35 |
+
|
| 36 |
+
def test_enabled_values(self):
|
| 37 |
+
from agent.agent_runtime_helpers import prompt_caching_disabled_from_config
|
| 38 |
+
|
| 39 |
+
for ttl in ("5m", "1h"):
|
| 40 |
+
with patch(
|
| 41 |
+
"hermes_cli.config.load_config_readonly",
|
| 42 |
+
return_value={"prompt_caching": {"cache_ttl": ttl}},
|
| 43 |
+
):
|
| 44 |
+
assert prompt_caching_disabled_from_config() is False, ttl
|
| 45 |
+
|
| 46 |
+
def test_shared_predicate_matches_agent_init_semantics(self):
|
| 47 |
+
"""agent_init and the stub paths must share one disable predicate.
|
| 48 |
+
|
| 49 |
+
Unknown values keep caching enabled (default TTL), exactly like the
|
| 50 |
+
historical inline detection in agent_init (#76085 drift guard).
|
| 51 |
+
"""
|
| 52 |
+
from agent.agent_runtime_helpers import cache_ttl_means_disabled
|
| 53 |
+
|
| 54 |
+
for ttl in (False, None, "off", "false", "disabled", "no", "none", "OFF"):
|
| 55 |
+
assert cache_ttl_means_disabled(ttl) is True, ttl
|
| 56 |
+
for ttl in ("5m", "1h", "2h", 5, True, "weird"):
|
| 57 |
+
assert cache_ttl_means_disabled(ttl) is False, ttl
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class TestPlanCacheSectionsHonorsDisable:
|
| 61 |
+
def test_explicit_cache_disabled_strips_markers(self):
|
| 62 |
+
from agent.agent_runtime_helpers import plan_cache_sections_for_destination
|
| 63 |
+
|
| 64 |
+
messages = [
|
| 65 |
+
{"role": "system", "content": "You are helpful."},
|
| 66 |
+
{"role": "user", "content": "hello"},
|
| 67 |
+
]
|
| 68 |
+
tools = [
|
| 69 |
+
{
|
| 70 |
+
"type": "function",
|
| 71 |
+
"function": {
|
| 72 |
+
"name": "web_search",
|
| 73 |
+
"description": "search",
|
| 74 |
+
"parameters": {"type": "object", "properties": {}},
|
| 75 |
+
},
|
| 76 |
+
}
|
| 77 |
+
]
|
| 78 |
+
out_msgs, out_tools = plan_cache_sections_for_destination(
|
| 79 |
+
messages,
|
| 80 |
+
tools,
|
| 81 |
+
provider="anthropic",
|
| 82 |
+
base_url="https://api.anthropic.com",
|
| 83 |
+
api_mode="anthropic_messages",
|
| 84 |
+
model="claude-opus-4.8",
|
| 85 |
+
cache_disabled=True,
|
| 86 |
+
)
|
| 87 |
+
assert not _has_cache_control(out_msgs)
|
| 88 |
+
assert not _has_cache_control(out_tools)
|
| 89 |
+
|
| 90 |
+
def test_config_off_without_explicit_flag(self):
|
| 91 |
+
from agent.agent_runtime_helpers import plan_cache_sections_for_destination
|
| 92 |
+
|
| 93 |
+
messages = [
|
| 94 |
+
{"role": "system", "content": "You are helpful."},
|
| 95 |
+
{"role": "user", "content": "hello"},
|
| 96 |
+
]
|
| 97 |
+
with patch(
|
| 98 |
+
"hermes_cli.config.load_config_readonly",
|
| 99 |
+
return_value={"prompt_caching": {"cache_ttl": "off"}},
|
| 100 |
+
):
|
| 101 |
+
out_msgs, out_tools = plan_cache_sections_for_destination(
|
| 102 |
+
messages,
|
| 103 |
+
None,
|
| 104 |
+
provider="anthropic",
|
| 105 |
+
base_url="https://api.anthropic.com",
|
| 106 |
+
api_mode="anthropic_messages",
|
| 107 |
+
model="claude-opus-4.8",
|
| 108 |
+
)
|
| 109 |
+
assert not _has_cache_control(out_msgs)
|
| 110 |
+
assert out_tools is None or not _has_cache_control(out_tools)
|
| 111 |
+
|
| 112 |
+
def test_enabled_still_adds_markers_on_native_anthropic(self):
|
| 113 |
+
from agent.agent_runtime_helpers import plan_cache_sections_for_destination
|
| 114 |
+
|
| 115 |
+
messages = [
|
| 116 |
+
{"role": "system", "content": "You are helpful."},
|
| 117 |
+
{"role": "user", "content": "hello"},
|
| 118 |
+
{"role": "assistant", "content": "hi"},
|
| 119 |
+
{"role": "user", "content": "again"},
|
| 120 |
+
]
|
| 121 |
+
with patch(
|
| 122 |
+
"hermes_cli.config.load_config_readonly",
|
| 123 |
+
return_value={"prompt_caching": {"cache_ttl": "5m"}},
|
| 124 |
+
):
|
| 125 |
+
out_msgs, _ = plan_cache_sections_for_destination(
|
| 126 |
+
messages,
|
| 127 |
+
None,
|
| 128 |
+
provider="anthropic",
|
| 129 |
+
base_url="https://api.anthropic.com",
|
| 130 |
+
api_mode="anthropic_messages",
|
| 131 |
+
model="claude-opus-4.8",
|
| 132 |
+
cache_disabled=False,
|
| 133 |
+
)
|
| 134 |
+
assert _has_cache_control(out_msgs), (
|
| 135 |
+
"With caching enabled, native Anthropic destinations must still "
|
| 136 |
+
"receive cache_control breakpoints."
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class TestMoASlotDecorationHonorsDisable:
|
| 141 |
+
def test_maybe_apply_skips_markers_when_disabled(self):
|
| 142 |
+
from agent.moa_loop import _maybe_apply_moa_cache_control
|
| 143 |
+
|
| 144 |
+
messages = [
|
| 145 |
+
{"role": "system", "content": "sys"},
|
| 146 |
+
{"role": "user", "content": "q1"},
|
| 147 |
+
{"role": "assistant", "content": "a1"},
|
| 148 |
+
{"role": "user", "content": "q2"},
|
| 149 |
+
]
|
| 150 |
+
runtime = {
|
| 151 |
+
"provider": "anthropic",
|
| 152 |
+
"model": "claude-opus-4.8",
|
| 153 |
+
"base_url": "",
|
| 154 |
+
"api_mode": "anthropic_messages",
|
| 155 |
+
}
|
| 156 |
+
out = _maybe_apply_moa_cache_control(
|
| 157 |
+
messages, runtime, cache_disabled=True
|
| 158 |
+
)
|
| 159 |
+
assert not _has_cache_control(out)
|
| 160 |
+
# Inputs must not be mutated.
|
| 161 |
+
assert not _has_cache_control(messages)
|
| 162 |
+
|
| 163 |
+
def test_maybe_apply_config_off(self):
|
| 164 |
+
from agent.moa_loop import _maybe_apply_moa_cache_control
|
| 165 |
+
|
| 166 |
+
messages = [
|
| 167 |
+
{"role": "system", "content": "sys"},
|
| 168 |
+
{"role": "user", "content": "q1"},
|
| 169 |
+
{"role": "assistant", "content": "a1"},
|
| 170 |
+
{"role": "user", "content": "q2"},
|
| 171 |
+
]
|
| 172 |
+
runtime = {
|
| 173 |
+
"provider": "anthropic",
|
| 174 |
+
"model": "claude-opus-4.8",
|
| 175 |
+
"base_url": "",
|
| 176 |
+
"api_mode": "anthropic_messages",
|
| 177 |
+
}
|
| 178 |
+
with patch(
|
| 179 |
+
"hermes_cli.config.load_config_readonly",
|
| 180 |
+
return_value={"prompt_caching": {"cache_ttl": False}},
|
| 181 |
+
):
|
| 182 |
+
out = _maybe_apply_moa_cache_control(messages, runtime)
|
| 183 |
+
assert not _has_cache_control(out)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class TestPreparedAggregatorNoAgentConfigOff:
|
| 187 |
+
"""Prepared-aggregator facades from ``MoAChatCompletions.__new__`` have
|
| 188 |
+
no ``_agent``. The planner must not raise and must honor config-off.
|
| 189 |
+
"""
|
| 190 |
+
|
| 191 |
+
def test_prepared_aggregator_without_agent_honors_config_off(self):
|
| 192 |
+
import copy
|
| 193 |
+
|
| 194 |
+
from agent import moa_loop
|
| 195 |
+
|
| 196 |
+
calls = []
|
| 197 |
+
with (
|
| 198 |
+
patch.object(
|
| 199 |
+
moa_loop,
|
| 200 |
+
"call_llm",
|
| 201 |
+
side_effect=lambda **kwargs: calls.append(kwargs) or SimpleNamespace(
|
| 202 |
+
choices=[SimpleNamespace(
|
| 203 |
+
message=SimpleNamespace(content="ok", tool_calls=[]),
|
| 204 |
+
finish_reason="stop",
|
| 205 |
+
)],
|
| 206 |
+
usage=None,
|
| 207 |
+
model="fake",
|
| 208 |
+
),
|
| 209 |
+
),
|
| 210 |
+
patch.object(
|
| 211 |
+
moa_loop,
|
| 212 |
+
"_slot_runtime",
|
| 213 |
+
return_value={
|
| 214 |
+
"provider": "anthropic",
|
| 215 |
+
"model": "claude-sonnet-4-6",
|
| 216 |
+
"base_url": "https://api.anthropic.com",
|
| 217 |
+
"api_mode": "anthropic_messages",
|
| 218 |
+
},
|
| 219 |
+
),
|
| 220 |
+
patch(
|
| 221 |
+
"hermes_cli.config.load_config_readonly",
|
| 222 |
+
return_value={"prompt_caching": {"cache_ttl": "off"}},
|
| 223 |
+
),
|
| 224 |
+
):
|
| 225 |
+
completions = moa_loop.MoAChatCompletions.__new__(
|
| 226 |
+
moa_loop.MoAChatCompletions
|
| 227 |
+
)
|
| 228 |
+
# No _agent attribute — the regression surface from review.
|
| 229 |
+
completions._pending_trace = None
|
| 230 |
+
prepared = {
|
| 231 |
+
"messages": [
|
| 232 |
+
{"role": "system", "content": "system"},
|
| 233 |
+
{"role": "user", "content": "lookup"},
|
| 234 |
+
{
|
| 235 |
+
"role": "assistant",
|
| 236 |
+
"content": "",
|
| 237 |
+
"tool_calls": [{
|
| 238 |
+
"id": "lookup",
|
| 239 |
+
"function": {"name": "lookup", "arguments": "{}"},
|
| 240 |
+
}],
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"role": "tool",
|
| 244 |
+
"tool_call_id": "lookup",
|
| 245 |
+
"content": "result",
|
| 246 |
+
},
|
| 247 |
+
],
|
| 248 |
+
"guidance": None,
|
| 249 |
+
"aggregator": {
|
| 250 |
+
"provider": "anthropic",
|
| 251 |
+
"model": "claude-sonnet-4-6",
|
| 252 |
+
},
|
| 253 |
+
"aggregator_temperature": None,
|
| 254 |
+
}
|
| 255 |
+
tools = [{
|
| 256 |
+
"type": "function",
|
| 257 |
+
"function": {
|
| 258 |
+
"name": "lookup",
|
| 259 |
+
"parameters": {"type": "object", "properties": {}},
|
| 260 |
+
},
|
| 261 |
+
}]
|
| 262 |
+
canonical_tools = copy.deepcopy(tools)
|
| 263 |
+
|
| 264 |
+
completions._call_prepared_aggregator(prepared, {"tools": tools})
|
| 265 |
+
|
| 266 |
+
assert calls, "prepared aggregator must still call the LLM"
|
| 267 |
+
assert not _has_cache_control(calls[0].get("tools") or []), (
|
| 268 |
+
"config cache_ttl=off with no live _agent must not inject "
|
| 269 |
+
"cache_control (planner config fallback, not forced False)."
|
| 270 |
+
)
|
| 271 |
+
assert not _has_cache_control(calls[0].get("messages") or [])
|
| 272 |
+
assert tools == canonical_tools
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
class TestBlankCachePolicyStubFactory:
|
| 276 |
+
def test_factory_sets_cache_disabled_from_config(self):
|
| 277 |
+
from agent.agent_runtime_helpers import blank_cache_policy_stub
|
| 278 |
+
|
| 279 |
+
with patch(
|
| 280 |
+
"hermes_cli.config.load_config_readonly",
|
| 281 |
+
return_value={"prompt_caching": {"cache_ttl": "off"}},
|
| 282 |
+
):
|
| 283 |
+
stub = blank_cache_policy_stub()
|
| 284 |
+
assert stub._cache_disabled is True
|
| 285 |
+
|
| 286 |
+
def test_factory_honors_explicit_false(self):
|
| 287 |
+
from agent.agent_runtime_helpers import blank_cache_policy_stub
|
| 288 |
+
|
| 289 |
+
with patch(
|
| 290 |
+
"hermes_cli.config.load_config_readonly",
|
| 291 |
+
return_value={"prompt_caching": {"cache_ttl": "off"}},
|
| 292 |
+
):
|
| 293 |
+
stub = blank_cache_policy_stub(False)
|
| 294 |
+
assert stub._cache_disabled is False
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
class TestOneShotSynthesisAgentDisable:
|
| 298 |
+
"""aggregate_moa_context must pin agent._cache_disabled onto decoration
|
| 299 |
+
so the one-shot synthesis path cannot re-enable markers mid-session.
|
| 300 |
+
"""
|
| 301 |
+
|
| 302 |
+
def test_synthesis_untouched_when_agent_disables_cache(self):
|
| 303 |
+
from agent import moa_loop
|
| 304 |
+
|
| 305 |
+
calls = []
|
| 306 |
+
with (
|
| 307 |
+
patch.object(
|
| 308 |
+
moa_loop,
|
| 309 |
+
"call_llm",
|
| 310 |
+
side_effect=lambda **kwargs: calls.append(kwargs) or SimpleNamespace(
|
| 311 |
+
choices=[SimpleNamespace(
|
| 312 |
+
message=SimpleNamespace(content="synth", tool_calls=[]),
|
| 313 |
+
finish_reason="stop",
|
| 314 |
+
)],
|
| 315 |
+
usage=None,
|
| 316 |
+
model="fake",
|
| 317 |
+
),
|
| 318 |
+
),
|
| 319 |
+
patch.object(
|
| 320 |
+
moa_loop,
|
| 321 |
+
"_run_references_parallel",
|
| 322 |
+
return_value=[("advisor-a", "advice from a", None)],
|
| 323 |
+
),
|
| 324 |
+
patch.object(
|
| 325 |
+
moa_loop,
|
| 326 |
+
"_slot_runtime",
|
| 327 |
+
return_value={
|
| 328 |
+
"provider": "anthropic",
|
| 329 |
+
"model": "claude-opus-4.8",
|
| 330 |
+
"base_url": "",
|
| 331 |
+
"api_mode": "anthropic_messages",
|
| 332 |
+
},
|
| 333 |
+
),
|
| 334 |
+
# Config would enable caching; agent snapshot must win.
|
| 335 |
+
patch(
|
| 336 |
+
"hermes_cli.config.load_config_readonly",
|
| 337 |
+
return_value={"prompt_caching": {"cache_ttl": "5m"}},
|
| 338 |
+
),
|
| 339 |
+
):
|
| 340 |
+
moa_loop.aggregate_moa_context(
|
| 341 |
+
user_prompt="what should I do next?",
|
| 342 |
+
api_messages=[{"role": "user", "content": "help me plan"}],
|
| 343 |
+
reference_models=[{"provider": "openrouter", "model": "openai/gpt-5.5"}],
|
| 344 |
+
aggregator={"provider": "anthropic", "model": "claude-opus-4.8"},
|
| 345 |
+
agent=SimpleNamespace(_cache_disabled=True),
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
assert calls, "synthesis must still call the LLM"
|
| 349 |
+
synth_msgs = calls[0].get("messages") or []
|
| 350 |
+
assert not _has_cache_control(synth_msgs), (
|
| 351 |
+
"agent._cache_disabled must keep the one-shot synthesis "
|
| 352 |
+
"message undecorated even on a cache-honoring route"
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
class TestAdvisorRuntimeDisable:
|
| 357 |
+
def test_maybe_apply_honors_runtime_cache_disabled_snapshot(self):
|
| 358 |
+
from agent.moa_loop import _maybe_apply_moa_cache_control
|
| 359 |
+
|
| 360 |
+
messages = [
|
| 361 |
+
{"role": "system", "content": "advisor"},
|
| 362 |
+
{"role": "user", "content": "review"},
|
| 363 |
+
{"role": "assistant", "content": "a1"},
|
| 364 |
+
{"role": "user", "content": "again"},
|
| 365 |
+
]
|
| 366 |
+
with patch(
|
| 367 |
+
"hermes_cli.config.load_config_readonly",
|
| 368 |
+
return_value={"prompt_caching": {"cache_ttl": "5m"}},
|
| 369 |
+
):
|
| 370 |
+
out = _maybe_apply_moa_cache_control(
|
| 371 |
+
messages,
|
| 372 |
+
{
|
| 373 |
+
"provider": "anthropic",
|
| 374 |
+
"model": "claude-sonnet-4-6",
|
| 375 |
+
"base_url": "https://api.anthropic.com",
|
| 376 |
+
"api_mode": "anthropic_messages",
|
| 377 |
+
"_cache_disabled": True,
|
| 378 |
+
},
|
| 379 |
+
)
|
| 380 |
+
assert not _has_cache_control(out)
|
| 381 |
+
# Inputs must not be mutated.
|
| 382 |
+
assert not _has_cache_control(messages)
|
tests/agent/test_chat_completion_helpers_provider_sort.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agent.chat_completion_helpers import _validated_openrouter_provider_sort
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_validated_openrouter_provider_sort_accepts_valid_values():
|
| 5 |
+
assert _validated_openrouter_provider_sort("price") == "price"
|
| 6 |
+
assert _validated_openrouter_provider_sort(" latency ") == "latency"
|
| 7 |
+
assert _validated_openrouter_provider_sort("THROUGHPUT") == "throughput"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_validated_openrouter_provider_sort_rejects_invalid_values():
|
| 11 |
+
assert _validated_openrouter_provider_sort("intelligence") is None
|
| 12 |
+
assert _validated_openrouter_provider_sort("") is None
|
| 13 |
+
assert _validated_openrouter_provider_sort(None) is None
|
tests/agent/test_codex_app_server_event_bridge.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for the codex_app_server → Hermes UI event bridge.
|
| 2 |
+
|
| 3 |
+
Pin the translation of codex JSON-RPC notifications into agent callbacks
|
| 4 |
+
(`tool_progress_callback`, `_fire_stream_delta`, `_fire_reasoning_delta`,
|
| 5 |
+
`_emit_interim_assistant_message`) so Discord/Telegram/TUI continue to
|
| 6 |
+
surface live tool-progress bubbles and interim assistant commentary when
|
| 7 |
+
the active provider runs on `openai_runtime: codex_app_server` (#33200).
|
| 8 |
+
|
| 9 |
+
Each test drives `make_codex_app_server_event_bridge(agent)` directly with
|
| 10 |
+
fixture notifications that mirror codex 0.130.0's `item/*` shape and
|
| 11 |
+
asserts the right agent callback fired with the right arguments. The
|
| 12 |
+
bridge is deliberately small (~150 lines of pure dict mapping) so the
|
| 13 |
+
tests can stay focused on the wire format ↔ callback contract.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
from types import SimpleNamespace
|
| 20 |
+
from unittest.mock import MagicMock
|
| 21 |
+
|
| 22 |
+
import pytest
|
| 23 |
+
|
| 24 |
+
from agent.codex_runtime import (
|
| 25 |
+
_codex_item_completion_payload,
|
| 26 |
+
_codex_item_to_args,
|
| 27 |
+
_codex_item_to_preview,
|
| 28 |
+
_codex_item_to_tool_name,
|
| 29 |
+
make_codex_app_server_event_bridge,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _make_stub_agent() -> SimpleNamespace:
|
| 34 |
+
"""Minimal stand-in for AIAgent that records every callback fire."""
|
| 35 |
+
return SimpleNamespace(
|
| 36 |
+
tool_progress_callback=MagicMock(name="tool_progress_callback"),
|
| 37 |
+
_fire_stream_delta=MagicMock(name="_fire_stream_delta"),
|
| 38 |
+
_fire_reasoning_delta=MagicMock(name="_fire_reasoning_delta"),
|
| 39 |
+
_emit_interim_assistant_message=MagicMock(
|
| 40 |
+
name="_emit_interim_assistant_message"
|
| 41 |
+
),
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _item_started(item: dict) -> dict:
|
| 46 |
+
return {"method": "item/started", "params": {"item": item}}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _item_completed(item: dict) -> dict:
|
| 50 |
+
return {"method": "item/completed", "params": {"item": item}}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ---------- name / args / preview / result mapping ----------
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class TestCodexItemToToolName:
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_dynamic_tool_call_uses_tool_field(self):
|
| 62 |
+
assert _codex_item_to_tool_name(
|
| 63 |
+
{"type": "dynamicToolCall", "tool": "web_search"}
|
| 64 |
+
) == "web_search"
|
| 65 |
+
|
| 66 |
+
def test_hermes_tools_mcp_server_emits_bare_tool_name(self):
|
| 67 |
+
"""The hermes-tools MCP server wraps Hermes' own tools for codex;
|
| 68 |
+
the inner dispatch subprocess can't fire native progress events,
|
| 69 |
+
so the codex-level event IS the display event — shown without the
|
| 70 |
+
mcp.hermes-tools.* namespacing (from #26541 by @simpolism)."""
|
| 71 |
+
assert _codex_item_to_tool_name(
|
| 72 |
+
{"type": "mcpToolCall", "server": "hermes-tools", "tool": "web_search"}
|
| 73 |
+
) == "web_search"
|
| 74 |
+
assert _codex_item_to_tool_name(
|
| 75 |
+
{"type": "mcpToolCall", "server": "hermes-tools", "tool": "browser_navigate"}
|
| 76 |
+
) == "browser_navigate"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class TestCodexItemToArgs:
|
| 83 |
+
|
| 84 |
+
def test_file_change_args_normalize_changes(self):
|
| 85 |
+
args = _codex_item_to_args({
|
| 86 |
+
"type": "fileChange",
|
| 87 |
+
"changes": [
|
| 88 |
+
{"path": "/a.py", "kind": {"type": "add"}},
|
| 89 |
+
{"path": "/b.py", "kind": {"type": "update"}},
|
| 90 |
+
"not-a-dict",
|
| 91 |
+
],
|
| 92 |
+
})
|
| 93 |
+
assert args == {
|
| 94 |
+
"changes": [
|
| 95 |
+
{"kind": "add", "path": "/a.py"},
|
| 96 |
+
{"kind": "update", "path": "/b.py"},
|
| 97 |
+
]
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_non_dict_arguments_get_wrapped(self):
|
| 102 |
+
args = _codex_item_to_args({
|
| 103 |
+
"type": "dynamicToolCall", "arguments": ["a", "b"],
|
| 104 |
+
})
|
| 105 |
+
assert args == {"arguments": ["a", "b"]}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class TestCodexItemToPreview:
|
| 109 |
+
def test_command_preview_truncated(self):
|
| 110 |
+
long_cmd = "echo " + "x" * 500
|
| 111 |
+
preview = _codex_item_to_preview({
|
| 112 |
+
"type": "commandExecution", "command": long_cmd
|
| 113 |
+
})
|
| 114 |
+
assert preview is not None
|
| 115 |
+
assert len(preview) <= 120
|
| 116 |
+
|
| 117 |
+
def test_file_change_preview_lists_first_three_paths(self):
|
| 118 |
+
preview = _codex_item_to_preview({
|
| 119 |
+
"type": "fileChange",
|
| 120 |
+
"changes": [
|
| 121 |
+
{"path": f"/p{i}.py", "kind": {"type": "update"}}
|
| 122 |
+
for i in range(5)
|
| 123 |
+
],
|
| 124 |
+
})
|
| 125 |
+
assert "/p0.py" in preview and "/p2.py" in preview
|
| 126 |
+
assert "+2 more" in preview
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class TestCodexItemCompletionPayload:
|
| 133 |
+
def test_command_success_returns_aggregated_output(self):
|
| 134 |
+
result, is_error = _codex_item_completion_payload({
|
| 135 |
+
"type": "commandExecution",
|
| 136 |
+
"exitCode": 0,
|
| 137 |
+
"aggregatedOutput": "hello\nworld\n",
|
| 138 |
+
})
|
| 139 |
+
assert result == "hello\nworld\n"
|
| 140 |
+
assert is_error is False
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_mcp_tool_error_is_error(self):
|
| 145 |
+
result, is_error = _codex_item_completion_payload({
|
| 146 |
+
"type": "mcpToolCall",
|
| 147 |
+
"error": {"message": "nope"},
|
| 148 |
+
})
|
| 149 |
+
assert "[error]" in result
|
| 150 |
+
assert is_error is True
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ---------- bridge: dispatch contracts ----------
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class TestStreamDeltaDispatch:
|
| 158 |
+
def test_agent_message_delta_fires_stream_delta(self):
|
| 159 |
+
agent = _make_stub_agent()
|
| 160 |
+
bridge = make_codex_app_server_event_bridge(agent)
|
| 161 |
+
bridge({"method": "item/agentMessage/delta",
|
| 162 |
+
"params": {"delta": "hello "}})
|
| 163 |
+
bridge({"method": "item/agentMessage/delta",
|
| 164 |
+
"params": {"delta": "world"}})
|
| 165 |
+
assert agent._fire_stream_delta.call_count == 2
|
| 166 |
+
assert agent._fire_stream_delta.call_args_list[0].args == ("hello ",)
|
| 167 |
+
assert agent._fire_stream_delta.call_args_list[1].args == ("world",)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_reasoning_delta_fires_reasoning_callback(self):
|
| 172 |
+
agent = _make_stub_agent()
|
| 173 |
+
bridge = make_codex_app_server_event_bridge(agent)
|
| 174 |
+
bridge({"method": "item/reasoning/delta",
|
| 175 |
+
"params": {"delta": "thinking..."}})
|
| 176 |
+
agent._fire_reasoning_delta.assert_called_once_with("thinking...")
|
| 177 |
+
agent._fire_stream_delta.assert_not_called()
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class TestToolProgressDispatch:
|
| 181 |
+
def test_command_started_fires_tool_started(self):
|
| 182 |
+
agent = _make_stub_agent()
|
| 183 |
+
bridge = make_codex_app_server_event_bridge(agent)
|
| 184 |
+
bridge(_item_started({
|
| 185 |
+
"type": "commandExecution",
|
| 186 |
+
"id": "exec-1",
|
| 187 |
+
"command": "ls /tmp",
|
| 188 |
+
"cwd": "/tmp",
|
| 189 |
+
}))
|
| 190 |
+
agent.tool_progress_callback.assert_called_once()
|
| 191 |
+
call = agent.tool_progress_callback.call_args
|
| 192 |
+
assert call.args[0] == "tool.started"
|
| 193 |
+
assert call.args[1] == "exec_command"
|
| 194 |
+
assert "ls /tmp" in call.args[2] # preview
|
| 195 |
+
assert call.args[3] == {"command": "ls /tmp", "cwd": "/tmp"}
|
| 196 |
+
|
| 197 |
+
def test_command_completed_fires_tool_completed_with_result(self):
|
| 198 |
+
agent = _make_stub_agent()
|
| 199 |
+
bridge = make_codex_app_server_event_bridge(agent)
|
| 200 |
+
bridge(_item_started({
|
| 201 |
+
"type": "commandExecution",
|
| 202 |
+
"id": "exec-2",
|
| 203 |
+
"command": "echo hi",
|
| 204 |
+
"cwd": "/tmp",
|
| 205 |
+
}))
|
| 206 |
+
bridge(_item_completed({
|
| 207 |
+
"type": "commandExecution",
|
| 208 |
+
"id": "exec-2",
|
| 209 |
+
"exitCode": 0,
|
| 210 |
+
"aggregatedOutput": "hi\n",
|
| 211 |
+
"durationMs": 42,
|
| 212 |
+
}))
|
| 213 |
+
# tool.started then tool.completed
|
| 214 |
+
assert agent.tool_progress_callback.call_count == 2
|
| 215 |
+
completed = agent.tool_progress_callback.call_args_list[1]
|
| 216 |
+
assert completed.args[0] == "tool.completed"
|
| 217 |
+
assert completed.args[1] == "exec_command"
|
| 218 |
+
assert completed.args[2] is None # preview unused on completion
|
| 219 |
+
assert completed.args[3] is None # args unused on completion
|
| 220 |
+
assert completed.kwargs["duration"] == pytest.approx(0.042)
|
| 221 |
+
assert completed.kwargs["is_error"] is False
|
| 222 |
+
assert completed.kwargs["result"] == "hi\n"
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_web_search_builtin_fires_started_and_completed(self):
|
| 229 |
+
"""Codex's built-in webSearch produces a start/complete bubble pair
|
| 230 |
+
with the query as preview and args (#26541)."""
|
| 231 |
+
agent = _make_stub_agent()
|
| 232 |
+
bridge = make_codex_app_server_event_bridge(agent)
|
| 233 |
+
bridge(_item_started({
|
| 234 |
+
"type": "webSearch",
|
| 235 |
+
"id": "ws-1",
|
| 236 |
+
"query": "hermes agent docs",
|
| 237 |
+
}))
|
| 238 |
+
bridge(_item_completed({
|
| 239 |
+
"type": "webSearch",
|
| 240 |
+
"id": "ws-1",
|
| 241 |
+
"query": "hermes agent docs",
|
| 242 |
+
}))
|
| 243 |
+
calls = agent.tool_progress_callback.call_args_list
|
| 244 |
+
assert [c.args[0] for c in calls] == ["tool.started", "tool.completed"]
|
| 245 |
+
assert calls[0].args[1] == "web_search"
|
| 246 |
+
assert calls[0].args[2] == "hermes agent docs"
|
| 247 |
+
assert calls[0].args[3] == {"query": "hermes agent docs"}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class TestAgentMessageInterimDispatch:
|
| 253 |
+
def test_completed_agent_message_emits_interim(self):
|
| 254 |
+
agent = _make_stub_agent()
|
| 255 |
+
bridge = make_codex_app_server_event_bridge(agent)
|
| 256 |
+
bridge(_item_completed({
|
| 257 |
+
"type": "agentMessage",
|
| 258 |
+
"id": "am-1",
|
| 259 |
+
"text": "I'll check the config first.",
|
| 260 |
+
}))
|
| 261 |
+
agent._emit_interim_assistant_message.assert_called_once_with(
|
| 262 |
+
{"role": "assistant", "content": "I'll check the config first."}
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def test_show_commentary_off_suppresses_interim(self):
|
| 268 |
+
"""display.show_commentary=false silences agentMessage interim
|
| 269 |
+
delivery on the app-server runtime (same contract as the
|
| 270 |
+
codex_responses commentary channel)."""
|
| 271 |
+
agent = _make_stub_agent()
|
| 272 |
+
agent.show_commentary = False
|
| 273 |
+
bridge = make_codex_app_server_event_bridge(agent)
|
| 274 |
+
bridge(_item_completed({
|
| 275 |
+
"type": "agentMessage", "id": "am-5", "text": "I'll check config.",
|
| 276 |
+
}))
|
| 277 |
+
agent._emit_interim_assistant_message.assert_not_called()
|
| 278 |
+
# Tool progress is unaffected by the commentary toggle.
|
| 279 |
+
bridge(_item_started({
|
| 280 |
+
"type": "commandExecution", "id": "cmd-1", "command": "ls",
|
| 281 |
+
}))
|
| 282 |
+
agent.tool_progress_callback.assert_called_once()
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
class TestBridgeRobustness:
|
| 286 |
+
|
| 287 |
+
def test_missing_params_is_ignored(self):
|
| 288 |
+
agent = _make_stub_agent()
|
| 289 |
+
bridge = make_codex_app_server_event_bridge(agent)
|
| 290 |
+
bridge({"method": "item/started"})
|
| 291 |
+
bridge({"method": "item/completed"})
|
| 292 |
+
agent.tool_progress_callback.assert_not_called()
|
| 293 |
+
|
| 294 |
+
def test_callback_exceptions_do_not_propagate(self):
|
| 295 |
+
# Buggy display callbacks must not tear down the codex turn loop.
|
| 296 |
+
agent = _make_stub_agent()
|
| 297 |
+
agent.tool_progress_callback.side_effect = RuntimeError("boom")
|
| 298 |
+
agent._fire_stream_delta.side_effect = RuntimeError("boom")
|
| 299 |
+
agent._emit_interim_assistant_message.side_effect = RuntimeError("boom")
|
| 300 |
+
bridge = make_codex_app_server_event_bridge(agent)
|
| 301 |
+
# All three paths must swallow exceptions silently.
|
| 302 |
+
bridge(_item_started({
|
| 303 |
+
"type": "commandExecution", "id": "exec-x", "command": "ls",
|
| 304 |
+
}))
|
| 305 |
+
bridge({"method": "item/agentMessage/delta",
|
| 306 |
+
"params": {"delta": "x"}})
|
| 307 |
+
bridge(_item_completed({
|
| 308 |
+
"type": "agentMessage", "id": "am-x", "text": "hi",
|
| 309 |
+
}))
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
# ---------- end-to-end: bridge is wired in run_codex_app_server_turn ----------
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
class TestBridgeWiredInRuntime:
|
| 318 |
+
"""Verify run_codex_app_server_turn actually constructs the session
|
| 319 |
+
with `on_event=<bridge>`. This is the integration guard that prevents
|
| 320 |
+
a future refactor from dropping the bridge wiring and silently
|
| 321 |
+
regressing Discord/Telegram live progress visibility."""
|
| 322 |
+
|
| 323 |
+
def test_session_constructor_receives_on_event(self, monkeypatch):
|
| 324 |
+
from agent import codex_runtime
|
| 325 |
+
|
| 326 |
+
captured: dict = {}
|
| 327 |
+
|
| 328 |
+
class FakeSession:
|
| 329 |
+
def __init__(self, **kwargs):
|
| 330 |
+
captured.update(kwargs)
|
| 331 |
+
|
| 332 |
+
def run_turn(self, user_input, **_):
|
| 333 |
+
from agent.transports.codex_app_server_session import TurnResult
|
| 334 |
+
return TurnResult(
|
| 335 |
+
final_text="done",
|
| 336 |
+
projected_messages=[],
|
| 337 |
+
tool_iterations=0,
|
| 338 |
+
turn_id="t1",
|
| 339 |
+
thread_id="th1",
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
def close(self):
|
| 343 |
+
pass
|
| 344 |
+
|
| 345 |
+
monkeypatch.setattr(
|
| 346 |
+
"agent.transports.codex_app_server_session.CodexAppServerSession",
|
| 347 |
+
FakeSession,
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
# Minimal stub agent — the runtime only touches a handful of
|
| 351 |
+
# attributes and we mock the heavy ones to keep the test fast.
|
| 352 |
+
agent = SimpleNamespace(
|
| 353 |
+
session_cwd=None,
|
| 354 |
+
_codex_session=None,
|
| 355 |
+
tool_progress_callback=MagicMock(),
|
| 356 |
+
_fire_stream_delta=MagicMock(),
|
| 357 |
+
_fire_reasoning_delta=MagicMock(),
|
| 358 |
+
_emit_interim_assistant_message=MagicMock(),
|
| 359 |
+
_iters_since_skill=0,
|
| 360 |
+
_skill_nudge_interval=0,
|
| 361 |
+
valid_tool_names=set(),
|
| 362 |
+
_sync_external_memory_for_turn=lambda **_: None,
|
| 363 |
+
_spawn_background_review=lambda **_: None,
|
| 364 |
+
# Usage accounting attrs read by _record_codex_app_server_usage.
|
| 365 |
+
session_api_calls=0,
|
| 366 |
+
session_prompt_tokens=0,
|
| 367 |
+
session_completion_tokens=0,
|
| 368 |
+
session_reasoning_tokens=0,
|
| 369 |
+
session_cached_tokens=0,
|
| 370 |
+
session_total_tokens=0,
|
| 371 |
+
context_compressor=None,
|
| 372 |
+
event_callback=None,
|
| 373 |
+
_session_db=None,
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
codex_runtime.run_codex_app_server_turn(
|
| 377 |
+
agent,
|
| 378 |
+
user_message="hi",
|
| 379 |
+
original_user_message="hi",
|
| 380 |
+
messages=[],
|
| 381 |
+
effective_task_id="t",
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
assert "on_event" in captured, (
|
| 385 |
+
"run_codex_app_server_turn must pass on_event=<bridge> to the "
|
| 386 |
+
"session — without it the gateway sees no live progress (#33200)"
|
| 387 |
+
)
|
| 388 |
+
assert callable(captured["on_event"]), (
|
| 389 |
+
"on_event must be the bridge callable, not None or a sentinel"
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
# And the bridge must actually drive the agent's callbacks when
|
| 393 |
+
# fed a representative notification.
|
| 394 |
+
captured["on_event"](_item_started({
|
| 395 |
+
"type": "commandExecution",
|
| 396 |
+
"id": "wired-1",
|
| 397 |
+
"command": "ls",
|
| 398 |
+
}))
|
| 399 |
+
agent.tool_progress_callback.assert_called_once()
|
| 400 |
+
assert agent.tool_progress_callback.call_args.args[0] == "tool.started"
|
| 401 |
+
assert agent.tool_progress_callback.call_args.args[1] == "exec_command"
|
tests/agent/test_codex_app_server_lifecycle.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Codex app-server session lifecycle on hard agent teardown (#65260).
|
| 2 |
+
|
| 3 |
+
The Codex runtime drops ``agent._codex_session`` on turn crash and on
|
| 4 |
+
retirement (agent/codex_runtime.py), but ``AIAgent.close()`` — the hard
|
| 5 |
+
teardown for /new, /reset, and session expiry — had no owner for it, so
|
| 6 |
+
the app-server child process survived until interpreter exit.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import threading
|
| 10 |
+
|
| 11 |
+
from run_agent import AIAgent
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class _FakeCodexSession:
|
| 15 |
+
def __init__(self, raises: bool = False):
|
| 16 |
+
self.close_calls = 0
|
| 17 |
+
self._raises = raises
|
| 18 |
+
|
| 19 |
+
def close(self):
|
| 20 |
+
self.close_calls += 1
|
| 21 |
+
if self._raises:
|
| 22 |
+
raise RuntimeError("app-server already dead")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _bare_agent(session_id: str) -> AIAgent:
|
| 26 |
+
"""Minimal agent shell exercising close() without a real build."""
|
| 27 |
+
agent = AIAgent.__new__(AIAgent)
|
| 28 |
+
agent.session_id = session_id
|
| 29 |
+
agent.client = None
|
| 30 |
+
agent._active_children_lock = threading.Lock()
|
| 31 |
+
agent._active_children = set()
|
| 32 |
+
agent._end_session_on_close = False
|
| 33 |
+
agent._session_messages = ["retained"]
|
| 34 |
+
return agent
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_agent_close_releases_codex_app_server_session(monkeypatch):
|
| 38 |
+
agent = _bare_agent("test-codex-lifecycle")
|
| 39 |
+
codex_session = _FakeCodexSession()
|
| 40 |
+
agent._codex_session = codex_session
|
| 41 |
+
|
| 42 |
+
monkeypatch.setattr("run_agent.cleanup_vm", lambda _task_id: None)
|
| 43 |
+
monkeypatch.setattr("run_agent.cleanup_browser", lambda _task_id: None)
|
| 44 |
+
|
| 45 |
+
agent.close()
|
| 46 |
+
agent.close()
|
| 47 |
+
|
| 48 |
+
# Idempotent: the second close must not re-close a released session.
|
| 49 |
+
assert codex_session.close_calls == 1
|
| 50 |
+
assert agent._codex_session is None
|
| 51 |
+
assert agent._session_messages == []
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_close_clears_reference_even_when_session_close_raises(monkeypatch):
|
| 55 |
+
"""A wedged app-server must not strand a stale session reference.
|
| 56 |
+
|
| 57 |
+
The attribute is cleared BEFORE close() precisely so a raising close
|
| 58 |
+
can't leave a dead session attached to the agent.
|
| 59 |
+
"""
|
| 60 |
+
agent = _bare_agent("test-codex-lifecycle-raises")
|
| 61 |
+
codex_session = _FakeCodexSession(raises=True)
|
| 62 |
+
agent._codex_session = codex_session
|
| 63 |
+
|
| 64 |
+
monkeypatch.setattr("run_agent.cleanup_vm", lambda _task_id: None)
|
| 65 |
+
monkeypatch.setattr("run_agent.cleanup_browser", lambda _task_id: None)
|
| 66 |
+
|
| 67 |
+
agent.close()
|
| 68 |
+
|
| 69 |
+
assert codex_session.close_calls == 1
|
| 70 |
+
assert agent._codex_session is None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_close_without_codex_session_is_a_noop(monkeypatch):
|
| 74 |
+
"""Non-Codex sessions (the common case) must be unaffected."""
|
| 75 |
+
agent = _bare_agent("test-no-codex")
|
| 76 |
+
|
| 77 |
+
monkeypatch.setattr("run_agent.cleanup_vm", lambda _task_id: None)
|
| 78 |
+
monkeypatch.setattr("run_agent.cleanup_browser", lambda _task_id: None)
|
| 79 |
+
|
| 80 |
+
agent.close()
|
| 81 |
+
|
| 82 |
+
assert getattr(agent, "_codex_session", None) is None
|
tests/agent/test_codex_echo_ownership.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The accepted input owns its row; transport echoes do not own new rows."""
|
| 2 |
+
import threading
|
| 3 |
+
from types import SimpleNamespace
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from agent.codex_runtime import _persist_projected_messages
|
| 8 |
+
from agent.message_metadata import append_message
|
| 9 |
+
from agent.session_persistence import SessionPersistenceMixin
|
| 10 |
+
from agent.transports.codex_event_projector import CodexEventProjector
|
| 11 |
+
from hermes_state import SessionDB
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@pytest.mark.parametrize("platform_id", ["2146", None])
|
| 15 |
+
@pytest.mark.parametrize("projection", ["echo", "assistant_only", "different", "later_equal"])
|
| 16 |
+
def test_only_submitted_leading_echo_is_excluded(tmp_path, platform_id, projection):
|
| 17 |
+
db = SessionDB(tmp_path / "state.db")
|
| 18 |
+
try:
|
| 19 |
+
db.create_session(session_id="echo", source="telegram", model="codex")
|
| 20 |
+
agent = SessionPersistenceMixin()
|
| 21 |
+
agent.session_id = "echo"
|
| 22 |
+
agent._session_db = db
|
| 23 |
+
agent._session_db_created = True
|
| 24 |
+
agent._last_flushed_db_idx = 0
|
| 25 |
+
agent._session_persist_lock = threading.RLock()
|
| 26 |
+
messages = []
|
| 27 |
+
# Two independently accepted identical inputs must both survive.
|
| 28 |
+
for turn_index in range(2):
|
| 29 |
+
append_message(messages, {"role": "user", "content": "accepted", "platform_message_id": platform_id})
|
| 30 |
+
assert agent._flush_messages_to_session_db(messages)
|
| 31 |
+
projector = CodexEventProjector()
|
| 32 |
+
items = []
|
| 33 |
+
if projection != "assistant_only":
|
| 34 |
+
items.append({"type": "userMessage", "id": f"u{turn_index}", "content": [
|
| 35 |
+
{"type": "text", "text": "different" if projection == "different" else "wire caption"}]})
|
| 36 |
+
items.append({"type": "agentMessage", "id": f"a{turn_index}", "text": "reply"})
|
| 37 |
+
if projection == "later_equal":
|
| 38 |
+
items.append({"type": "userMessage", "id": f"s{turn_index}", "content": [
|
| 39 |
+
{"type": "text", "text": "wire caption"}]})
|
| 40 |
+
projected = []
|
| 41 |
+
for item in items:
|
| 42 |
+
projected.extend(projector.project({"method": "item/completed", "params": {"item": item}}).messages)
|
| 43 |
+
_persist_projected_messages(agent, SimpleNamespace(
|
| 44 |
+
projected_messages=projected, submitted_user_text="wire caption"), messages)
|
| 45 |
+
assert agent._flush_messages_to_session_db(messages)
|
| 46 |
+
users = [row["content"] for row in db.get_messages_as_conversation("echo") if row["role"] == "user"]
|
| 47 |
+
extra = {"different": ["different"], "later_equal": ["wire caption"]}.get(projection, [])
|
| 48 |
+
assert users == (["accepted"] + extra) * 2
|
| 49 |
+
finally:
|
| 50 |
+
db.close()
|
tests/agent/test_codex_no_tools_nonetype.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression coverage for #32892.
|
| 2 |
+
|
| 3 |
+
The openai SDK's ``responses.stream()`` / ``responses.parse()`` eagerly
|
| 4 |
+
call ``_make_tools(tools)``, which iterates ``tools`` *without* a None
|
| 5 |
+
guard. Passing ``tools=None`` therefore raises::
|
| 6 |
+
|
| 7 |
+
TypeError: 'NoneType' object is not iterable
|
| 8 |
+
|
| 9 |
+
…before any HTTP request is issued. This trips the
|
| 10 |
+
``openai-codex`` / ``gpt-5.5`` combo on ``chatgpt.com/backend-api/codex``
|
| 11 |
+
whenever the user runs Hermes without external tools registered: the
|
| 12 |
+
agent loop catches the TypeError, sees no HTTP status, classifies it as
|
| 13 |
+
non-retryable, and aborts (#32892).
|
| 14 |
+
|
| 15 |
+
These tests pin the defence:
|
| 16 |
+
:func:`agent.transports.codex.ResponsesApiTransport.build_kwargs` must
|
| 17 |
+
never emit ``tools=None`` — only add the ``tools`` key when there are
|
| 18 |
+
function tools to expose. When there are no tools, the entire ``tools``
|
| 19 |
+
key (plus ``tool_choice`` and ``parallel_tool_calls`` which are
|
| 20 |
+
meaningless without it) is omitted from the kwargs.
|
| 21 |
+
|
| 22 |
+
Note: #33042 separately removed the SDK's ``responses.stream()`` helper
|
| 23 |
+
from our own Codex call paths, so the specific iteration crash inside
|
| 24 |
+
``_make_tools`` is also structurally avoided in normal operation. This
|
| 25 |
+
test class additionally pins the SDK's ``_make_tools(None)`` contract so
|
| 26 |
+
we notice if upstream ever changes it.
|
| 27 |
+
"""
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import sys
|
| 31 |
+
import types
|
| 32 |
+
from typing import Any, Dict, List
|
| 33 |
+
|
| 34 |
+
import pytest
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# Stub optional deps the parent module imports at top level — keeps this
|
| 38 |
+
# test file runnable in the same environment as the existing Codex tests.
|
| 39 |
+
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
|
| 40 |
+
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
|
| 41 |
+
sys.modules.setdefault("fal_client", types.SimpleNamespace())
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# Helpers
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@pytest.fixture
|
| 50 |
+
def transport():
|
| 51 |
+
"""Fresh ``ResponsesApiTransport`` per test (it is stateless but
|
| 52 |
+
the import has side-effects on a global transport registry)."""
|
| 53 |
+
from agent.transports.codex import ResponsesApiTransport
|
| 54 |
+
|
| 55 |
+
return ResponsesApiTransport()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@pytest.fixture
|
| 59 |
+
def codex_messages() -> List[Dict[str, Any]]:
|
| 60 |
+
"""Minimal Codex-shaped chat history mirroring the #32892 reproducer:
|
| 61 |
+
one system + one short user message, with no tool calls in history."""
|
| 62 |
+
return [
|
| 63 |
+
{"role": "system", "content": "You are Hermes."},
|
| 64 |
+
{"role": "user", "content": "Hey! What can I help you with?"},
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _build_kwargs_no_tools(transport, messages) -> Dict[str, Any]:
|
| 69 |
+
"""Exercise the real ``build_kwargs`` for the codex backend with no tools."""
|
| 70 |
+
return transport.build_kwargs(
|
| 71 |
+
model="gpt-5.5",
|
| 72 |
+
messages=messages,
|
| 73 |
+
tools=None,
|
| 74 |
+
is_codex_backend=True,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
# build_kwargs: the "tools=None" key must never appear
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_build_kwargs_omits_tools_key_when_no_tools(transport, codex_messages):
|
| 84 |
+
"""``build_kwargs`` must not place ``tools=None`` in the outgoing dict.
|
| 85 |
+
|
| 86 |
+
Putting ``tools=None`` reaches ``responses.stream()`` which calls
|
| 87 |
+
``_make_tools(None)`` and crashes with the #32892 TypeError before any
|
| 88 |
+
request is sent.
|
| 89 |
+
"""
|
| 90 |
+
kwargs = _build_kwargs_no_tools(transport, codex_messages)
|
| 91 |
+
|
| 92 |
+
assert "tools" not in kwargs, (
|
| 93 |
+
f"tools key must be omitted entirely when no tools are registered, "
|
| 94 |
+
f"got kwargs={sorted(kwargs)}"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_build_kwargs_keeps_required_codex_fields_without_tools(transport, codex_messages):
|
| 101 |
+
"""The toolless build must still emit the non-negotiable Codex fields
|
| 102 |
+
(model / instructions / input / store) — otherwise we'd just be moving
|
| 103 |
+
the bug from the SDK to preflight."""
|
| 104 |
+
kwargs = _build_kwargs_no_tools(transport, codex_messages)
|
| 105 |
+
|
| 106 |
+
assert kwargs["model"] == "gpt-5.5"
|
| 107 |
+
assert kwargs["instructions"] == "You are Hermes."
|
| 108 |
+
assert kwargs["store"] is False
|
| 109 |
+
assert isinstance(kwargs["input"], list)
|
| 110 |
+
assert kwargs["input"] and kwargs["input"][0]["role"] == "user"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_openai_sdk_raises_typeerror_on_tools_none():
|
| 122 |
+
"""Document the upstream behaviour the two defences guard against.
|
| 123 |
+
|
| 124 |
+
If the SDK ever fixes ``_make_tools(None)`` to return ``omit``
|
| 125 |
+
gracefully, this test will start failing — at which point the agent
|
| 126 |
+
defences become belt-only and this test should be flipped to an
|
| 127 |
+
``xfail`` so we notice the upstream change.
|
| 128 |
+
"""
|
| 129 |
+
from openai.resources.responses.responses import _make_tools
|
| 130 |
+
|
| 131 |
+
with pytest.raises(TypeError, match="NoneType.*not iterable"):
|
| 132 |
+
_make_tools(None)
|
tests/agent/test_command_secret_source.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""E2E tests for the ``command`` secret source.
|
| 2 |
+
|
| 3 |
+
These exercise the REAL resolution path: real helper shell scripts written
|
| 4 |
+
to a temp dir (chmod +x), real ``/bin/sh -c`` subprocesses, and a real temp
|
| 5 |
+
HERMES_HOME with a config.yaml routing ``secrets.provider: command`` through
|
| 6 |
+
``hermes_cli.env_loader._apply_external_secret_sources``.
|
| 7 |
+
|
| 8 |
+
Security invariants under test (ported from the desktop TS provider):
|
| 9 |
+
|
| 10 |
+
* the requested key travels ONLY via the ``HERMES_SECRET_KEY`` env var —
|
| 11 |
+
never interpolated into the shell string (hostile key names are inert);
|
| 12 |
+
* hard timeout + degrade-to-empty on every failure mode, never raise;
|
| 13 |
+
* failure logging carries structured fields only — never the command
|
| 14 |
+
string or any secret value.
|
| 15 |
+
|
| 16 |
+
NOTE: tests assert on key NAMES, lengths, and presence — never log secret
|
| 17 |
+
values themselves.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import stat
|
| 24 |
+
import sys
|
| 25 |
+
import time
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import pytest
|
| 29 |
+
|
| 30 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 31 |
+
if str(ROOT) not in sys.path:
|
| 32 |
+
sys.path.insert(0, str(ROOT))
|
| 33 |
+
|
| 34 |
+
from agent.secret_sources.command import ( # noqa: E402
|
| 35 |
+
CommandSource,
|
| 36 |
+
_run_helper,
|
| 37 |
+
unquote_dotenv_value,
|
| 38 |
+
)
|
| 39 |
+
from agent.secret_sources.base import ( # noqa: E402
|
| 40 |
+
reset_source_environment,
|
| 41 |
+
set_source_environment,
|
| 42 |
+
)
|
| 43 |
+
from hermes_cli import env_loader # noqa: E402
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
pytestmark = pytest.mark.skipif(
|
| 47 |
+
os.name == "nt", reason="the command secret provider is POSIX-only"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _write_helper(tmp_path: Path, body: str, name: str = "helper.sh") -> Path:
|
| 52 |
+
"""Write a real executable helper script and return its path."""
|
| 53 |
+
script = tmp_path / name
|
| 54 |
+
script.write_text("#!/bin/sh\n" + body + "\n", encoding="utf-8")
|
| 55 |
+
script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
| 56 |
+
return script
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_profile_helper_does_not_inherit_process_secret(monkeypatch):
|
| 60 |
+
monkeypatch.setenv("LEAK_CANARY", "global-secret")
|
| 61 |
+
token = set_source_environment({"PROFILE_ONLY": "profile-value"})
|
| 62 |
+
try:
|
| 63 |
+
output = _run_helper(
|
| 64 |
+
'printf "%s|%s" "${LEAK_CANARY-unset}" "$PROFILE_ONLY"',
|
| 65 |
+
"",
|
| 66 |
+
1.0,
|
| 67 |
+
1024,
|
| 68 |
+
)
|
| 69 |
+
finally:
|
| 70 |
+
reset_source_environment(token)
|
| 71 |
+
|
| 72 |
+
assert output == "unset|profile-value"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@pytest.fixture(autouse=True)
|
| 76 |
+
def _clean_env(monkeypatch):
|
| 77 |
+
"""Each test starts with a clean source map, applied-home guard, and no
|
| 78 |
+
leftover test keys in os.environ."""
|
| 79 |
+
env_loader._SECRET_SOURCES.clear()
|
| 80 |
+
env_loader.reset_secret_source_cache()
|
| 81 |
+
for key in ("CMDTEST_API_KEY", "CMDTEST_TOKEN", "CMDTEST_OTHER_KEY"):
|
| 82 |
+
monkeypatch.delenv(key, raising=False)
|
| 83 |
+
yield
|
| 84 |
+
env_loader._SECRET_SOURCES.clear()
|
| 85 |
+
env_loader.reset_secret_source_cache()
|
| 86 |
+
for key in ("CMDTEST_API_KEY", "CMDTEST_TOKEN", "CMDTEST_OTHER_KEY"):
|
| 87 |
+
os.environ.pop(key, None)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
# Parsing semantics (pure functions, mirroring the TS parseSecretOutput)
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_unquote_strips_one_layer_of_matching_quotes():
|
| 96 |
+
assert unquote_dotenv_value('"abc"') == "abc"
|
| 97 |
+
assert unquote_dotenv_value("'abc'") == "abc"
|
| 98 |
+
assert unquote_dotenv_value('""') == ""
|
| 99 |
+
assert unquote_dotenv_value('"') == '"' # lone quote left intact
|
| 100 |
+
assert unquote_dotenv_value(" plain ") == "plain"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
# Real-subprocess resolution
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_helper_stdout_is_returned_verbatim(tmp_path):
|
| 109 |
+
helper = _write_helper(tmp_path, "printf 'sk-test-bare-12345'")
|
| 110 |
+
value = _run_helper(str(helper), "CMDTEST_API_KEY", 3.0, 1024)
|
| 111 |
+
assert value == "sk-test-bare-12345"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_timeout_kills_hung_helper_and_degrades_to_empty(tmp_path):
|
| 115 |
+
helper = _write_helper(tmp_path, "sleep 30")
|
| 116 |
+
start = time.monotonic()
|
| 117 |
+
value = _run_helper(str(helper), "CMDTEST_API_KEY", 2.0, 1024)
|
| 118 |
+
elapsed = time.monotonic() - start
|
| 119 |
+
assert value is None
|
| 120 |
+
assert elapsed < 6.0, f"helper not killed within the bound (took {elapsed:.1f}s)"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_failure_logging_never_leaks_command_or_secret(tmp_path, capfd):
|
| 124 |
+
secret_value = "sk-super-secret-value-do-not-log"
|
| 125 |
+
helper = _write_helper(
|
| 126 |
+
tmp_path,
|
| 127 |
+
f"echo '{secret_value}' >&2\nexit 7",
|
| 128 |
+
name="my-distinctive-helper-name.sh",
|
| 129 |
+
)
|
| 130 |
+
value = _run_helper(str(helper), "CMDTEST_API_KEY", 3.0, 1024)
|
| 131 |
+
assert value is None
|
| 132 |
+
captured = capfd.readouterr()
|
| 133 |
+
combined = captured.out + captured.err
|
| 134 |
+
# Structured fields only — never the command string, the helper's
|
| 135 |
+
# stderr, or any secret value.
|
| 136 |
+
assert "my-distinctive-helper-name" not in combined
|
| 137 |
+
assert secret_value not in combined
|
| 138 |
+
assert "code=7" in combined # the structured field IS logged
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_fetch_parses_dotenv_blob(tmp_path):
|
| 142 |
+
helper = _write_helper(
|
| 143 |
+
tmp_path,
|
| 144 |
+
"printf 'CMDTEST_API_KEY=sk-applied\\nCMDTEST_TOKEN=tok-applied\\n'",
|
| 145 |
+
)
|
| 146 |
+
result = CommandSource().fetch({"enabled": True, "command": str(helper)}, tmp_path)
|
| 147 |
+
assert result.error is None
|
| 148 |
+
assert result.secrets == {"CMDTEST_API_KEY": "sk-applied", "CMDTEST_TOKEN": "tok-applied"}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ---------------------------------------------------------------------------
|
| 152 |
+
# Dispatch E2E through env_loader against a real temp HERMES_HOME
|
| 153 |
+
# ---------------------------------------------------------------------------
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@pytest.fixture(autouse=True)
|
| 157 |
+
def _clean_registry():
|
| 158 |
+
from agent.secret_sources import registry
|
| 159 |
+
registry._reset_registry_for_tests()
|
| 160 |
+
yield
|
| 161 |
+
registry._reset_registry_for_tests()
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_registry_command_source_applies_and_records_source(tmp_path, monkeypatch):
|
| 165 |
+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
| 166 |
+
helper = _write_helper(
|
| 167 |
+
tmp_path, "printf 'CMDTEST_API_KEY=sk-dispatch\\nCMDTEST_TOKEN=tok-dispatch\\n'"
|
| 168 |
+
)
|
| 169 |
+
(tmp_path / "config.yaml").write_text(
|
| 170 |
+
"secrets:\n"
|
| 171 |
+
" command:\n"
|
| 172 |
+
" enabled: true\n"
|
| 173 |
+
f" command: {helper}\n",
|
| 174 |
+
encoding="utf-8",
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
env_loader._apply_external_secret_sources(tmp_path)
|
| 178 |
+
|
| 179 |
+
assert os.environ.get("CMDTEST_API_KEY") == "sk-dispatch"
|
| 180 |
+
assert env_loader.get_secret_source("CMDTEST_API_KEY") == "command"
|
| 181 |
+
assert env_loader.get_secret_source("CMDTEST_TOKEN") == "command"
|
| 182 |
+
assert (
|
| 183 |
+
env_loader.format_secret_source_suffix("CMDTEST_API_KEY")
|
| 184 |
+
== " (from Command helper)"
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def test_registry_status_line_printed_once_per_home(tmp_path, monkeypatch, capsys):
|
| 189 |
+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
| 190 |
+
helper = _write_helper(tmp_path, "printf 'CMDTEST_API_KEY=sk-once\\n'")
|
| 191 |
+
(tmp_path / "config.yaml").write_text(
|
| 192 |
+
"secrets:\n command:\n enabled: true\n"
|
| 193 |
+
f" command: {helper}\n",
|
| 194 |
+
encoding="utf-8",
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
for _ in range(3): # idempotency guard: only the first call does work
|
| 198 |
+
env_loader._apply_external_secret_sources(tmp_path)
|
| 199 |
+
|
| 200 |
+
err = capsys.readouterr().err
|
| 201 |
+
assert err.count("Command helper: applied 1 secret") == 1
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_registry_failing_helper_does_not_block_startup(tmp_path, monkeypatch):
|
| 205 |
+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
| 206 |
+
(tmp_path / "config.yaml").write_text(
|
| 207 |
+
"secrets:\n command:\n enabled: true\n command: exit 9\n",
|
| 208 |
+
encoding="utf-8",
|
| 209 |
+
)
|
| 210 |
+
# Must not raise — config/helper errors never block startup.
|
| 211 |
+
env_loader._apply_external_secret_sources(tmp_path)
|
| 212 |
+
assert env_loader.get_secret_source("CMDTEST_API_KEY") is None
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def test_registry_helper_error_prints_remediation(tmp_path, monkeypatch, capsys):
|
| 216 |
+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
| 217 |
+
(tmp_path / "config.yaml").write_text(
|
| 218 |
+
"secrets:\n command:\n enabled: true\n command: ''\n",
|
| 219 |
+
encoding="utf-8",
|
| 220 |
+
)
|
| 221 |
+
env_loader._apply_external_secret_sources(tmp_path)
|
| 222 |
+
err = capsys.readouterr().err
|
| 223 |
+
assert "secrets.command.command" in err
|
tests/agent/test_compaction_ops_notification_anchor.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Operational notifications must not anchor compaction or auto-focus (#92703).
|
| 2 |
+
|
| 3 |
+
Kanban/background completion wakes are persisted as ``role="user"`` rows typed
|
| 4 |
+
with ``display_kind="internal_notification"`` (run.py -- the synthetic-wake
|
| 5 |
+
path). The model-payload builder already strips ``display_kind`` before the
|
| 6 |
+
request, and ``is_user_originated_turn`` already ignores it, but the compaction
|
| 7 |
+
tail-anchor / auto-focus scans must do the same. Otherwise routine machine
|
| 8 |
+
traffic becomes the "last user turn" the compressor protects and the focus
|
| 9 |
+
hint derives from, displacing the user's real objective.
|
| 10 |
+
|
| 11 |
+
These are behavior contracts against the real compressor functions, not mocks:
|
| 12 |
+
feed a transcript of 1,000 operational notifications around a single human turn
|
| 13 |
+
and assert the operational rows are invisible to the anchor/focus logic.
|
| 14 |
+
"""
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from agent.context_compressor import ContextCompressor
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _compressor() -> ContextCompressor:
|
| 21 |
+
cc = ContextCompressor(
|
| 22 |
+
model="test-model",
|
| 23 |
+
threshold_percent=0.75,
|
| 24 |
+
protect_first_n=5,
|
| 25 |
+
protect_last_n=20,
|
| 26 |
+
quiet_mode=True,
|
| 27 |
+
config_context_length=40960,
|
| 28 |
+
provider="test",
|
| 29 |
+
)
|
| 30 |
+
cc._generate_summary = lambda *a, **k: "Summary of earlier turns."
|
| 31 |
+
return cc
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _ops_notice(text: str) -> dict:
|
| 35 |
+
"""A Kanban/background completion wake, as persisted by the wake path."""
|
| 36 |
+
return {
|
| 37 |
+
"role": "user",
|
| 38 |
+
"content": text,
|
| 39 |
+
"display_kind": "internal_notification",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _human(text: str) -> dict:
|
| 44 |
+
return {"role": "user", "content": text}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _assistant(text: str) -> dict:
|
| 48 |
+
return {"role": "assistant", "content": text}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _transcript_with_n_ops(n: int, human_text: str = "Actually deploy the fix") -> list:
|
| 52 |
+
"""1,000 operational notifications with a single real human turn at the end."""
|
| 53 |
+
msgs: list = [{"role": "system", "content": "sys"}]
|
| 54 |
+
for i in range(n):
|
| 55 |
+
msgs.append(_ops_notice(f"✔ Kanban T-{i} done — worker summary line {i}"))
|
| 56 |
+
msgs.append(_assistant(f"Noted task {i} completion."))
|
| 57 |
+
msgs.append(_human(human_text))
|
| 58 |
+
msgs.append(_assistant("On it."))
|
| 59 |
+
return msgs
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_ops_notice_is_not_actionable_user_turn():
|
| 63 |
+
cc = _compressor()
|
| 64 |
+
assert cc._is_actionable_user_turn(_ops_notice("✔ Kanban T-1 done")) is False
|
| 65 |
+
# A real human turn still is.
|
| 66 |
+
assert cc._is_actionable_user_turn(_human("deploy the fix")) is True
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_ops_notices_do_not_anchor_compaction_tail():
|
| 70 |
+
cc = _compressor()
|
| 71 |
+
msgs = _transcript_with_n_ops(1000)
|
| 72 |
+
# The tail anchor must land on the human turn, not on any operational notice.
|
| 73 |
+
idx = cc._find_last_user_message_idx(msgs, head_end=0)
|
| 74 |
+
assert idx >= 0
|
| 75 |
+
assert msgs[idx]["role"] == "user"
|
| 76 |
+
assert msgs[idx].get("display_kind") is None, (
|
| 77 |
+
"compaction tail anchored on an operational notification, "
|
| 78 |
+
"not the user's real objective"
|
| 79 |
+
)
|
| 80 |
+
assert msgs[idx]["content"] == "Actually deploy the fix"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_ops_notices_do_not_become_auto_focus_source():
|
| 84 |
+
cc = _compressor()
|
| 85 |
+
msgs = _transcript_with_n_ops(1000, human_text="Summarize the Q3 roadmap")
|
| 86 |
+
focus = cc._derive_auto_focus_topic(msgs)
|
| 87 |
+
assert focus is not None
|
| 88 |
+
assert "Q3 roadmap" in focus, (
|
| 89 |
+
f"auto-focus derived from operational traffic instead of the user: {focus!r}"
|
| 90 |
+
)
|
| 91 |
+
assert "Kanban T-" not in focus, (
|
| 92 |
+
f"auto-focus leaked an operational notification: {focus!r}"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_conversational_user_count_unchanged_by_ops_notices():
|
| 97 |
+
"""1,000 notifications must not be counted as actionable user turns."""
|
| 98 |
+
cc = _compressor()
|
| 99 |
+
msgs = _transcript_with_n_ops(1000)
|
| 100 |
+
actionable = [m for m in msgs if cc._is_actionable_user_turn(m)]
|
| 101 |
+
# Exactly one: the human turn at the end.
|
| 102 |
+
assert len(actionable) == 1
|
| 103 |
+
assert actionable[0]["content"] == "Actually deploy the fix"
|
tests/agent/test_compaction_redaction_boundaries.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Strict redaction at every compaction text boundary (issue #43666 item 2).
|
| 2 |
+
|
| 3 |
+
Compaction summaries persist across sessions and re-enter every subsequent
|
| 4 |
+
summarizer prompt, so ``_redact_compaction_text()`` applies strict mode
|
| 5 |
+
(``force=True, redact_url_credentials=True``) at each boundary:
|
| 6 |
+
|
| 7 |
+
- serializer input (``_serialize_for_summary``: message content + tool args)
|
| 8 |
+
- deterministic fallback summary (``_build_static_fallback_summary``)
|
| 9 |
+
- summarizer LLM output (``_generate_summary`` return / ``_previous_summary``)
|
| 10 |
+
- focus text (manual ``/compress <focus>`` and ``_derive_auto_focus_topic``)
|
| 11 |
+
- previous-summary re-entry into the iterative-update prompt
|
| 12 |
+
|
| 13 |
+
Every test disables the global redaction flag (simulating
|
| 14 |
+
``security.redact_secrets: false``) to prove ``force=True`` still redacts at
|
| 15 |
+
the persistence boundary, and uses an OAuth-callback-style URL to prove
|
| 16 |
+
``redact_url_credentials=True`` strips opaque URL tokens that default-mode
|
| 17 |
+
redaction deliberately passes through.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from unittest.mock import MagicMock, patch
|
| 21 |
+
|
| 22 |
+
import pytest
|
| 23 |
+
|
| 24 |
+
from agent.context_compressor import (
|
| 25 |
+
ContextCompressor,
|
| 26 |
+
SUMMARY_PREFIX,
|
| 27 |
+
_redact_compaction_text,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
SECRET = "sk-proj-" + ("a" * 40)
|
| 31 |
+
OAUTH_URL = (
|
| 32 |
+
"https://localhost/callback?code=opaque-code-123"
|
| 33 |
+
"&access_token=opaque-token-456&state=keep"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.fixture(autouse=True)
|
| 38 |
+
def _redaction_globally_disabled(monkeypatch):
|
| 39 |
+
"""Simulate security.redact_secrets: false — force=True must still win."""
|
| 40 |
+
monkeypatch.setattr("agent.redact._REDACT_ENABLED", False)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _compressor() -> ContextCompressor:
|
| 44 |
+
with patch(
|
| 45 |
+
"agent.context_compressor.get_model_context_length",
|
| 46 |
+
return_value=100000,
|
| 47 |
+
):
|
| 48 |
+
return ContextCompressor(model="test/model", quiet_mode=True)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _response(content: str):
|
| 52 |
+
mock_response = MagicMock()
|
| 53 |
+
mock_response.choices = [MagicMock()]
|
| 54 |
+
mock_response.choices[0].message.content = content
|
| 55 |
+
return mock_response
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _assert_clean(text: str):
|
| 59 |
+
assert SECRET not in text
|
| 60 |
+
assert "sk-proj-" not in text
|
| 61 |
+
assert "code=opaque-code-123" not in text
|
| 62 |
+
assert "access_token=opaque-token-456" not in text
|
| 63 |
+
assert "code=***" in text
|
| 64 |
+
assert "access_token=***" in text
|
| 65 |
+
assert "state=keep" in text
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_helper_is_strict_even_when_redaction_disabled():
|
| 69 |
+
result = _redact_compaction_text(f"key {SECRET} url {OAUTH_URL}")
|
| 70 |
+
_assert_clean(result)
|
| 71 |
+
# None-safety: helper is used on optional fields.
|
| 72 |
+
assert _redact_compaction_text(None) == ""
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_serializer_input_redacts_content_and_tool_args():
|
| 76 |
+
c = _compressor()
|
| 77 |
+
messages = [
|
| 78 |
+
{"role": "user", "content": f"token {SECRET} url {OAUTH_URL}"},
|
| 79 |
+
{
|
| 80 |
+
"role": "assistant",
|
| 81 |
+
"content": None,
|
| 82 |
+
"tool_calls": [
|
| 83 |
+
{
|
| 84 |
+
"id": "call-1",
|
| 85 |
+
"function": {
|
| 86 |
+
"name": "terminal",
|
| 87 |
+
"arguments": (
|
| 88 |
+
f'{{"command": "curl {OAUTH_URL}",'
|
| 89 |
+
f' "note": "{SECRET}"}}'
|
| 90 |
+
),
|
| 91 |
+
},
|
| 92 |
+
}
|
| 93 |
+
],
|
| 94 |
+
},
|
| 95 |
+
{"role": "tool", "tool_call_id": "call-1", "content": f"got {SECRET}"},
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
serialized = c._serialize_for_summary(messages)
|
| 99 |
+
|
| 100 |
+
_assert_clean(serialized)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_fallback_summary_redacts_secrets():
|
| 104 |
+
c = _compressor()
|
| 105 |
+
turns = [
|
| 106 |
+
{"role": "user", "content": f"deploy with {SECRET} via {OAUTH_URL}"},
|
| 107 |
+
{"role": "assistant", "content": f"ran curl {OAUTH_URL}"},
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
summary = c._build_static_fallback_summary(turns, reason="test outage")
|
| 111 |
+
|
| 112 |
+
_assert_clean(summary)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_summary_output_redacts_llm_echoed_secrets():
|
| 116 |
+
c = _compressor()
|
| 117 |
+
leaked = f"Summary leaked OPENAI_API_KEY {SECRET} and {OAUTH_URL}"
|
| 118 |
+
|
| 119 |
+
with patch(
|
| 120 |
+
"agent.context_compressor.call_llm", return_value=_response(leaked)
|
| 121 |
+
):
|
| 122 |
+
summary = c._generate_summary([{"role": "user", "content": "hi"}])
|
| 123 |
+
|
| 124 |
+
assert summary is not None
|
| 125 |
+
_assert_clean(summary)
|
| 126 |
+
# The stored iterative-update seed must be clean too.
|
| 127 |
+
_assert_clean(c._previous_summary)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_manual_focus_topic_redacted_before_summary_prompt():
|
| 131 |
+
c = _compressor()
|
| 132 |
+
turns = [
|
| 133 |
+
{"role": "user", "content": "Summarize safely"},
|
| 134 |
+
{"role": "assistant", "content": "OK"},
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
with patch(
|
| 138 |
+
"agent.context_compressor.call_llm",
|
| 139 |
+
return_value=_response("## Goal\nSafe summary."),
|
| 140 |
+
) as mock_call:
|
| 141 |
+
result = c._generate_summary(
|
| 142 |
+
turns, focus_topic=f"manual focus {SECRET} {OAUTH_URL}"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
assert result is not None
|
| 146 |
+
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
| 147 |
+
_assert_clean(prompt)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_auto_focus_topic_redacted():
|
| 151 |
+
c = _compressor()
|
| 152 |
+
|
| 153 |
+
focus = c._derive_auto_focus_topic(
|
| 154 |
+
[
|
| 155 |
+
{"role": "assistant", "content": "older assistant turn"},
|
| 156 |
+
{"role": "user", "content": f"focus has {SECRET} and {OAUTH_URL}"},
|
| 157 |
+
]
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
assert focus is not None
|
| 161 |
+
_assert_clean(focus)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_previous_summary_redacted_before_iterative_prompt_reentry():
|
| 165 |
+
"""Legacy persisted summaries may predate compaction redaction."""
|
| 166 |
+
c = _compressor()
|
| 167 |
+
c._previous_summary = f"Old summary leaked {SECRET} and {OAUTH_URL}"
|
| 168 |
+
|
| 169 |
+
with patch(
|
| 170 |
+
"agent.context_compressor.call_llm",
|
| 171 |
+
return_value=_response("updated summary"),
|
| 172 |
+
) as mock_call:
|
| 173 |
+
result = c._generate_summary(
|
| 174 |
+
[
|
| 175 |
+
{"role": "user", "content": "new turn"},
|
| 176 |
+
{"role": "assistant", "content": "new work"},
|
| 177 |
+
]
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
assert result is not None
|
| 181 |
+
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
| 182 |
+
assert "PREVIOUS SUMMARY:" in prompt
|
| 183 |
+
_assert_clean(prompt)
|
| 184 |
+
# After generation, _previous_summary holds the new (clean) LLM output —
|
| 185 |
+
# the leaked secret must not have survived anywhere in it.
|
| 186 |
+
assert SECRET not in c._previous_summary
|
| 187 |
+
assert "access_token=opaque-token-456" not in c._previous_summary
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_resumed_handoff_summary_redacted_before_iterative_prompt():
|
| 191 |
+
"""Persisted handoff messages may contain pre-fix secrets after resume."""
|
| 192 |
+
with patch(
|
| 193 |
+
"agent.context_compressor.get_model_context_length",
|
| 194 |
+
return_value=100000,
|
| 195 |
+
):
|
| 196 |
+
c = ContextCompressor(
|
| 197 |
+
model="test/model",
|
| 198 |
+
threshold_percent=0.85,
|
| 199 |
+
protect_first_n=1,
|
| 200 |
+
protect_last_n=1,
|
| 201 |
+
quiet_mode=True,
|
| 202 |
+
)
|
| 203 |
+
old_summary = f"RESUMED-SUMMARY leaked {SECRET} and {OAUTH_URL}"
|
| 204 |
+
messages = [
|
| 205 |
+
{"role": "system", "content": "system prompt"},
|
| 206 |
+
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{old_summary}"},
|
| 207 |
+
{"role": "assistant", "content": "handoff acknowledged after resume"},
|
| 208 |
+
{"role": "user", "content": "new user turn after resume"},
|
| 209 |
+
{"role": "assistant", "content": "new assistant work after resume"},
|
| 210 |
+
{"role": "user", "content": "more new work after resume"},
|
| 211 |
+
{"role": "assistant", "content": "latest tail response"},
|
| 212 |
+
{"role": "user", "content": "final active request stays in tail"},
|
| 213 |
+
]
|
| 214 |
+
|
| 215 |
+
with patch(
|
| 216 |
+
"agent.context_compressor.call_llm",
|
| 217 |
+
return_value=_response("updated summary"),
|
| 218 |
+
) as mock_call:
|
| 219 |
+
c.compress(messages)
|
| 220 |
+
|
| 221 |
+
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
| 222 |
+
assert "PREVIOUS SUMMARY:" in prompt
|
| 223 |
+
_assert_clean(prompt)
|
tests/agent/test_compression_attempt_lifecycle.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Attempt-lifecycle regression tests for #97488 / #96775.
|
| 2 |
+
|
| 3 |
+
Pins the three attempt-level lifecycle guarantees added after PR #98628
|
| 4 |
+
collapsed lean compaction to one auxiliary request per attempt:
|
| 5 |
+
|
| 6 |
+
1. A ceiling/idle-timeout host TEARS DOWN its worker: a cooperative worker is
|
| 7 |
+
joined within the bounded grace (releasing the lease normally), while an
|
| 8 |
+
uninterruptible worker is orphaned behind the poison fence — its late
|
| 9 |
+
result is discarded and, on the total-ceiling path, the durable lease is
|
| 10 |
+
retained until it exits so no new attempt overlaps the unchanged session.
|
| 11 |
+
2. A failed/stalled/cancelled attempt records a durable per-session backoff
|
| 12 |
+
(strategy + failure kind stamped into the state.db cooldown row) that
|
| 13 |
+
SURVIVES a gateway restart; the next automatic turn skips the same
|
| 14 |
+
strategy inside the window, and a successful compression clears it.
|
| 15 |
+
3. Late results from a superseded attempt are discarded, never committed
|
| 16 |
+
over newer state (generation counter), and a transiently-blocked no-op is
|
| 17 |
+
reported as a soft defer — never compression_exhausted (false auto-reset).
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import copy
|
| 23 |
+
import os
|
| 24 |
+
import threading
|
| 25 |
+
import time
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
from unittest.mock import patch
|
| 28 |
+
|
| 29 |
+
import pytest
|
| 30 |
+
|
| 31 |
+
from agent.auxiliary_client import AuxiliaryExplicitCancellation
|
| 32 |
+
from agent.conversation_compression import (
|
| 33 |
+
CompressionCommitFence,
|
| 34 |
+
_claim_compressor_attempt,
|
| 35 |
+
compress_context,
|
| 36 |
+
compression_blocked_transiently,
|
| 37 |
+
run_compress_context_with_progress_timeout,
|
| 38 |
+
)
|
| 39 |
+
from hermes_state import SessionDB
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _build_agent(tmp_path: Path, session_id: str, db: SessionDB | None = None):
|
| 43 |
+
if db is None:
|
| 44 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 45 |
+
db.create_session(session_id, source="cli")
|
| 46 |
+
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
|
| 47 |
+
from run_agent import AIAgent
|
| 48 |
+
|
| 49 |
+
agent = AIAgent(
|
| 50 |
+
api_key="test-key",
|
| 51 |
+
base_url="https://openrouter.ai/api/v1",
|
| 52 |
+
model="test/model",
|
| 53 |
+
quiet_mode=True,
|
| 54 |
+
session_db=db,
|
| 55 |
+
session_id=session_id,
|
| 56 |
+
skip_context_files=True,
|
| 57 |
+
skip_memory=True,
|
| 58 |
+
)
|
| 59 |
+
agent._compression_feasibility_checked = True
|
| 60 |
+
agent.compression_in_place = True
|
| 61 |
+
agent._cached_system_prompt = "sys"
|
| 62 |
+
agent.context_compressor.threshold_tokens = 1_000
|
| 63 |
+
return db, agent
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _messages():
|
| 67 |
+
return [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class TestWorkerTeardownOnCeiling:
|
| 71 |
+
def test_cooperative_worker_joined_within_grace(self):
|
| 72 |
+
"""A worker that exits promptly after cancel is joined on the
|
| 73 |
+
total-ceiling path; the lease is released normally (no retention) —
|
| 74 |
+
the sabotage check for this test is removing the
|
| 75 |
+
`_join_cancelled_worker` call, which makes
|
| 76 |
+
`worker_done.is_set()` False when the host returns."""
|
| 77 |
+
original = [{"role": "user", "content": "keep"}]
|
| 78 |
+
worker_done = threading.Event()
|
| 79 |
+
|
| 80 |
+
def cooperative_worker(fence: CompressionCommitFence):
|
| 81 |
+
# Continuous progress (the #97488 'last progress 0.0s ago'
|
| 82 |
+
# shape) so only the TOTAL ceiling expires; poll the poison
|
| 83 |
+
# fence like the production worker does between provider phases.
|
| 84 |
+
deadline = time.monotonic() + 5.0
|
| 85 |
+
while time.monotonic() < deadline:
|
| 86 |
+
if fence.is_cancelled:
|
| 87 |
+
break
|
| 88 |
+
fence.touch_progress()
|
| 89 |
+
time.sleep(0.01)
|
| 90 |
+
# Cooperative-but-not-instant exit: the unwind after seeing the
|
| 91 |
+
# poison takes real time (rollback, telemetry). Long enough that
|
| 92 |
+
# a host WITHOUT the bounded-grace join returns first; far
|
| 93 |
+
# inside the 5s grace for a host WITH it.
|
| 94 |
+
time.sleep(0.08)
|
| 95 |
+
worker_done.set()
|
| 96 |
+
return (original, "late")
|
| 97 |
+
|
| 98 |
+
fence = CompressionCommitFence()
|
| 99 |
+
msgs, prompt = run_compress_context_with_progress_timeout(
|
| 100 |
+
worker=cooperative_worker,
|
| 101 |
+
messages=original,
|
| 102 |
+
system_prompt_fallback="fallback",
|
| 103 |
+
# Keep idle expiry out of this total-ceiling test under runner load.
|
| 104 |
+
idle_timeout_seconds=2.0,
|
| 105 |
+
total_ceiling_seconds=0.2,
|
| 106 |
+
fence=fence,
|
| 107 |
+
stall_fallback=False,
|
| 108 |
+
)
|
| 109 |
+
# The bounded-grace join must have reaped the cooperative worker
|
| 110 |
+
# BEFORE the host returned.
|
| 111 |
+
assert worker_done.is_set(), (
|
| 112 |
+
"host returned before tearing down a cooperative cancelled "
|
| 113 |
+
"worker — bounded-grace join missing (#97488)"
|
| 114 |
+
)
|
| 115 |
+
# Whichever return path won the race (fallback via join, or the
|
| 116 |
+
# worker's own return adopted inside the final wait slice), the
|
| 117 |
+
# transcript must be unchanged.
|
| 118 |
+
assert msgs == [{"role": "user", "content": "keep"}]
|
| 119 |
+
assert prompt in ("fallback", "late")
|
| 120 |
+
# Teardown proved quiescence, so the lease must NOT stay retained.
|
| 121 |
+
assert fence._retain_cancelled_lock_until_worker_done is False
|
| 122 |
+
|
| 123 |
+
def test_uninterruptible_worker_is_orphaned_with_lease_retained(self):
|
| 124 |
+
"""A worker stuck in an uninterruptible provider call is orphaned:
|
| 125 |
+
the host returns after the grace, the poison fence discards its late
|
| 126 |
+
result, and on the total-ceiling path the durable lease release hook
|
| 127 |
+
does NOT fire while the worker is alive (no overlap window)."""
|
| 128 |
+
original = [{"role": "user", "content": "keep"}]
|
| 129 |
+
release = threading.Event()
|
| 130 |
+
worker_finished = threading.Event()
|
| 131 |
+
lock_released: list[float] = []
|
| 132 |
+
|
| 133 |
+
def stuck_worker(fence: CompressionCommitFence):
|
| 134 |
+
# Continuous progress so only the TOTAL ceiling can expire
|
| 135 |
+
# (the #97488 'last progress 0.0s ago' shape).
|
| 136 |
+
while not release.wait(timeout=0.02):
|
| 137 |
+
fence.touch_progress()
|
| 138 |
+
worker_finished.set()
|
| 139 |
+
if not fence.begin_commit():
|
| 140 |
+
return (original, "")
|
| 141 |
+
try:
|
| 142 |
+
return ([{"role": "assistant", "content": "late"}], "late")
|
| 143 |
+
finally:
|
| 144 |
+
fence.finish_commit()
|
| 145 |
+
|
| 146 |
+
fence = CompressionCommitFence()
|
| 147 |
+
fence.register_cancelled_lock_release(
|
| 148 |
+
lambda: lock_released.append(time.monotonic())
|
| 149 |
+
)
|
| 150 |
+
msgs, prompt = run_compress_context_with_progress_timeout(
|
| 151 |
+
worker=stuck_worker,
|
| 152 |
+
messages=original,
|
| 153 |
+
system_prompt_fallback="fallback",
|
| 154 |
+
idle_timeout_seconds=0.1,
|
| 155 |
+
total_ceiling_seconds=0.3,
|
| 156 |
+
fence=fence,
|
| 157 |
+
stall_fallback=False,
|
| 158 |
+
)
|
| 159 |
+
# Precondition: the worker is genuinely still running.
|
| 160 |
+
assert not worker_finished.is_set()
|
| 161 |
+
assert msgs is original and prompt == "fallback"
|
| 162 |
+
# Total-ceiling path: lease retained until the worker exits, so no
|
| 163 |
+
# new attempt can overlap the unchanged session.
|
| 164 |
+
assert not lock_released, (
|
| 165 |
+
"durable lease released while the timed-out worker was still "
|
| 166 |
+
"alive — overlap window reopened (#97488)"
|
| 167 |
+
)
|
| 168 |
+
release.set()
|
| 169 |
+
assert worker_finished.wait(timeout=2)
|
| 170 |
+
# Late result was fence-poisoned, never adopted.
|
| 171 |
+
assert msgs == [{"role": "user", "content": "keep"}]
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class TestDurableAttemptBackoff:
|
| 175 |
+
def test_backoff_row_records_strategy_and_kind(self, tmp_path: Path):
|
| 176 |
+
db, agent = _build_agent(tmp_path, "BACKOFF_KIND")
|
| 177 |
+
agent.context_compressor.record_timeout_failure(
|
| 178 |
+
"host ceiling exhausted", failure_kind="ceiling_exhausted"
|
| 179 |
+
)
|
| 180 |
+
row = db.get_compression_failure_cooldown("BACKOFF_KIND")
|
| 181 |
+
assert row is not None, "backoff must persist to state.db"
|
| 182 |
+
assert row["remaining_seconds"] > 0
|
| 183 |
+
assert "backoff:ceiling_exhausted:strategy=lean" in (row["error"] or "")
|
| 184 |
+
|
| 185 |
+
def test_backoff_blocks_same_strategy_reentry_next_turn(self, tmp_path: Path):
|
| 186 |
+
db, agent = _build_agent(tmp_path, "BACKOFF_REENTRY")
|
| 187 |
+
agent.context_compressor.record_timeout_failure(
|
| 188 |
+
"stall", failure_kind="stalled"
|
| 189 |
+
)
|
| 190 |
+
# Precondition: over threshold, so only the backoff can block.
|
| 191 |
+
assert 500_000 >= agent.context_compressor.threshold_tokens
|
| 192 |
+
should, reason = agent.context_compressor.should_compress_info(500_000)
|
| 193 |
+
assert should is False
|
| 194 |
+
assert reason and reason.startswith("cooldown"), (
|
| 195 |
+
"next-turn re-entry of the same strategy must be skipped inside "
|
| 196 |
+
"the backoff window (#96775)"
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
def test_backoff_survives_simulated_gateway_restart(self, tmp_path: Path):
|
| 200 |
+
db, agent = _build_agent(tmp_path, "BACKOFF_RESTART")
|
| 201 |
+
agent.context_compressor.record_timeout_failure(
|
| 202 |
+
"stall before restart", failure_kind="stall_interrupted"
|
| 203 |
+
)
|
| 204 |
+
# Precondition: row is durable in this DB file.
|
| 205 |
+
assert db.get_compression_failure_cooldown("BACKOFF_RESTART")
|
| 206 |
+
# Simulated restart: brand-new SessionDB handle + brand-new agent
|
| 207 |
+
# objects rebuilt from the same state.db file.
|
| 208 |
+
db2 = SessionDB(db_path=tmp_path / "state.db")
|
| 209 |
+
_db2, agent2 = _build_agent(tmp_path, "BACKOFF_RESTART", db=db2)
|
| 210 |
+
cooldown = agent2.context_compressor.get_active_compression_failure_cooldown(
|
| 211 |
+
refresh=True
|
| 212 |
+
)
|
| 213 |
+
assert cooldown is not None, (
|
| 214 |
+
"backoff must survive a gateway restart via state.db (#96775)"
|
| 215 |
+
)
|
| 216 |
+
assert "stall_interrupted" in (cooldown["error"] or "")
|
| 217 |
+
should, reason = agent2.context_compressor.should_compress_info(500_000)
|
| 218 |
+
assert should is False and reason.startswith("cooldown")
|
| 219 |
+
|
| 220 |
+
def test_success_clears_backoff(self, tmp_path: Path):
|
| 221 |
+
db, agent = _build_agent(tmp_path, "BACKOFF_CLEAR")
|
| 222 |
+
compressor = agent.context_compressor
|
| 223 |
+
compressor.record_timeout_failure("stall", failure_kind="stalled")
|
| 224 |
+
assert db.get_compression_failure_cooldown("BACKOFF_CLEAR")
|
| 225 |
+
# What a successful compression does on commit:
|
| 226 |
+
compressor._clear_compression_failure_cooldown()
|
| 227 |
+
assert db.get_compression_failure_cooldown("BACKOFF_CLEAR") is None
|
| 228 |
+
assert compressor.should_compress_info(500_000)[0] is True
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class TestSupersessionDiscardsLateResults:
|
| 232 |
+
def test_superseded_attempt_candidate_never_commits(self, tmp_path: Path):
|
| 233 |
+
db, agent = _build_agent(tmp_path, "SUPERSEDE")
|
| 234 |
+
live = _messages()
|
| 235 |
+
original = copy.deepcopy(live)
|
| 236 |
+
|
| 237 |
+
def compress_and_get_superseded(messages, **_kwargs):
|
| 238 |
+
# While this attempt's summary was in flight, a NEWER attempt
|
| 239 |
+
# claimed the compressor (what a retry/fallback does).
|
| 240 |
+
_claim_compressor_attempt(agent.context_compressor)
|
| 241 |
+
return [{"role": "assistant", "content": "stale summary"}]
|
| 242 |
+
|
| 243 |
+
agent.context_compressor.compress = compress_and_get_superseded
|
| 244 |
+
out, _prompt = compress_context(
|
| 245 |
+
agent, live, "sys", approx_tokens=500_000
|
| 246 |
+
)
|
| 247 |
+
assert out == original, (
|
| 248 |
+
"late candidate from a superseded attempt must be discarded, "
|
| 249 |
+
"never committed over newer state (#97488)"
|
| 250 |
+
)
|
| 251 |
+
assert live == original
|
| 252 |
+
# Session stayed writable and unrotated.
|
| 253 |
+
assert db.get_compression_lock_holder("SUPERSEDE") is None
|
| 254 |
+
db.append_message("SUPERSEDE", "assistant", "still writable")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
class TestTransientBlockIsNotExhaustion:
|
| 258 |
+
def test_cooldown_blocked_noop_sets_transient_signal(self, tmp_path: Path):
|
| 259 |
+
db, agent = _build_agent(tmp_path, "TRANSIENT_SIGNAL")
|
| 260 |
+
agent.context_compressor.record_timeout_failure(
|
| 261 |
+
"host ceiling", failure_kind="ceiling_exhausted"
|
| 262 |
+
)
|
| 263 |
+
live = _messages()
|
| 264 |
+
before = copy.deepcopy(live)
|
| 265 |
+
out, _ = compress_context(agent, live, "sys", approx_tokens=500_000)
|
| 266 |
+
# Preconditions: the pass no-oped and it was NOT a lock skip.
|
| 267 |
+
assert out == before
|
| 268 |
+
assert getattr(agent, "_compression_skipped_due_to_lock", None) is None
|
| 269 |
+
assert compression_blocked_transiently(agent) is True, (
|
| 270 |
+
"a cooldown-blocked no-op must be distinguishable from "
|
| 271 |
+
"exhaustion or the gateway falsely auto-resets (#97488)"
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
def test_signal_cleared_per_attempt_and_not_set_when_unblocked(
|
| 275 |
+
self, tmp_path: Path
|
| 276 |
+
):
|
| 277 |
+
db, agent = _build_agent(tmp_path, "TRANSIENT_CLEAR")
|
| 278 |
+
# Stale signal from a previous pass must not leak.
|
| 279 |
+
agent._compression_blocked_transient = "cooldown:999"
|
| 280 |
+
agent.context_compressor.compress = lambda messages, **kw: list(messages)
|
| 281 |
+
live = _messages()
|
| 282 |
+
compress_context(agent, live, "sys", approx_tokens=500_000)
|
| 283 |
+
assert compression_blocked_transiently(agent) is False
|
| 284 |
+
|
| 285 |
+
def test_type_pinned_against_magicmock_agents(self):
|
| 286 |
+
from unittest.mock import MagicMock
|
| 287 |
+
|
| 288 |
+
mock_agent = MagicMock()
|
| 289 |
+
# MagicMock auto-attributes are truthy but not str.
|
| 290 |
+
assert compression_blocked_transiently(mock_agent) is False
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _summary_response(content: str):
|
| 294 |
+
from unittest.mock import MagicMock
|
| 295 |
+
|
| 296 |
+
response = MagicMock()
|
| 297 |
+
response.choices = [MagicMock()]
|
| 298 |
+
response.choices[0].message.content = content
|
| 299 |
+
return response
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
class TestProviderOverflowBypassesCooldown:
|
| 303 |
+
"""#100661: a provider-proven overflow must get one REAL summary attempt
|
| 304 |
+
while the summary-failure cooldown is armed. Before the fix every turn of
|
| 305 |
+
a wedged session hit the cooldown gate, returned the soft "temporarily
|
| 306 |
+
paused" deferral, and the next failure extended the ladder — 4 long
|
| 307 |
+
sessions were lost this way. Ordinary (non-overflow) automatic passes
|
| 308 |
+
must still defer."""
|
| 309 |
+
|
| 310 |
+
def _armed_agent(self, tmp_path: Path, session_id: str):
|
| 311 |
+
db, agent = _build_agent(tmp_path, session_id)
|
| 312 |
+
# Realistic arming: a failed/stalled attempt recorded the ladder.
|
| 313 |
+
agent.context_compressor.record_timeout_failure(
|
| 314 |
+
"stall", failure_kind="stalled"
|
| 315 |
+
)
|
| 316 |
+
assert agent.context_compressor.should_compress_info(500_000)[0] is False
|
| 317 |
+
return db, agent
|
| 318 |
+
|
| 319 |
+
def test_overflow_attempt_invokes_summarizer_while_cooldown_armed(
|
| 320 |
+
self, tmp_path: Path
|
| 321 |
+
):
|
| 322 |
+
db, agent = self._armed_agent(tmp_path, "OVERFLOW_BYPASS")
|
| 323 |
+
calls = []
|
| 324 |
+
|
| 325 |
+
def fake_call_llm(**kwargs):
|
| 326 |
+
calls.append(kwargs)
|
| 327 |
+
return _summary_response("## Goal\nRecovered after overflow.")
|
| 328 |
+
|
| 329 |
+
# Bulky turns so the compacted transcript is genuinely smaller.
|
| 330 |
+
live = [
|
| 331 |
+
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i} " * 400}
|
| 332 |
+
for i in range(20)
|
| 333 |
+
]
|
| 334 |
+
with patch("agent.context_compressor.call_llm", fake_call_llm):
|
| 335 |
+
out, _ = compress_context(
|
| 336 |
+
agent, live, "sys", approx_tokens=500_000, bypass_cooldown=True
|
| 337 |
+
)
|
| 338 |
+
assert len(calls) == 1, (
|
| 339 |
+
"provider-proven overflow must reach the summary LLM even while "
|
| 340 |
+
"the failure cooldown is armed (#100661)"
|
| 341 |
+
)
|
| 342 |
+
assert compression_blocked_transiently(agent) is False
|
| 343 |
+
assert len(out) < len(live), "the attempt must actually compact"
|
| 344 |
+
|
| 345 |
+
def test_non_overflow_pass_still_deferred_by_cooldown(self, tmp_path: Path):
|
| 346 |
+
db, agent = self._armed_agent(tmp_path, "OVERFLOW_ORDINARY")
|
| 347 |
+
calls = []
|
| 348 |
+
|
| 349 |
+
def fake_call_llm(**kwargs): # pragma: no cover - must not run
|
| 350 |
+
calls.append(kwargs)
|
| 351 |
+
return _summary_response("unexpected")
|
| 352 |
+
|
| 353 |
+
live = _messages()
|
| 354 |
+
before = copy.deepcopy(live)
|
| 355 |
+
with patch("agent.context_compressor.call_llm", fake_call_llm):
|
| 356 |
+
out, _ = compress_context(agent, live, "sys", approx_tokens=500_000)
|
| 357 |
+
assert calls == [] and out == before
|
| 358 |
+
assert compression_blocked_transiently(agent) is True, (
|
| 359 |
+
"ordinary threshold pressure keeps honoring the cooldown (#11529)"
|
| 360 |
+
)
|
tests/agent/test_compression_boundary.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for context compression boundary alignment.
|
| 2 |
+
|
| 3 |
+
Verifies that _align_boundary_backward correctly handles tool result groups
|
| 4 |
+
so that parallel tool calls are never split during compression.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from unittest.mock import patch
|
| 8 |
+
|
| 9 |
+
from agent.context_compressor import ContextCompressor
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# ---------------------------------------------------------------------------
|
| 13 |
+
# Helpers
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
|
| 16 |
+
def _tc(call_id: str) -> dict:
|
| 17 |
+
"""Create a minimal tool_call dict."""
|
| 18 |
+
return {"id": call_id, "type": "function", "function": {"name": "test", "arguments": "{}"}}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _tool_result(call_id: str, content: str = "result") -> dict:
|
| 22 |
+
"""Create a tool result message."""
|
| 23 |
+
return {"role": "tool", "tool_call_id": call_id, "content": content}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _assistant_with_tools(*call_ids: str) -> dict:
|
| 27 |
+
"""Create an assistant message with tool_calls."""
|
| 28 |
+
return {"role": "assistant", "tool_calls": [_tc(cid) for cid in call_ids], "content": None}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _make_compressor(**kwargs) -> ContextCompressor:
|
| 32 |
+
defaults = dict(
|
| 33 |
+
model="test-model",
|
| 34 |
+
threshold_percent=0.75,
|
| 35 |
+
protect_first_n=3,
|
| 36 |
+
protect_last_n=4,
|
| 37 |
+
quiet_mode=True,
|
| 38 |
+
)
|
| 39 |
+
defaults.update(kwargs)
|
| 40 |
+
with patch("agent.context_compressor.get_model_context_length", return_value=8000):
|
| 41 |
+
return ContextCompressor(**defaults)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# _align_boundary_backward tests
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
class TestAlignBoundaryBackward:
|
| 49 |
+
"""Test that compress-end boundary never splits a tool_call/result group."""
|
| 50 |
+
|
| 51 |
+
def test_boundary_at_clean_position(self):
|
| 52 |
+
"""Boundary after a user message — no adjustment needed."""
|
| 53 |
+
comp = _make_compressor()
|
| 54 |
+
messages = [
|
| 55 |
+
{"role": "system", "content": "sys"},
|
| 56 |
+
{"role": "user", "content": "hello"},
|
| 57 |
+
{"role": "assistant", "content": "hi"},
|
| 58 |
+
{"role": "user", "content": "do something"},
|
| 59 |
+
_assistant_with_tools("tc_1"),
|
| 60 |
+
_tool_result("tc_1", "done"),
|
| 61 |
+
{"role": "user", "content": "thanks"}, # idx=6
|
| 62 |
+
{"role": "assistant", "content": "np"},
|
| 63 |
+
]
|
| 64 |
+
# Boundary at 7, messages[6] = user — no adjustment
|
| 65 |
+
assert comp._align_boundary_backward(messages, 7) == 7
|
| 66 |
+
|
| 67 |
+
def test_boundary_after_assistant_with_tools(self):
|
| 68 |
+
"""Original case: boundary right after assistant with tool_calls."""
|
| 69 |
+
comp = _make_compressor()
|
| 70 |
+
messages = [
|
| 71 |
+
{"role": "system", "content": "sys"},
|
| 72 |
+
{"role": "user", "content": "hello"},
|
| 73 |
+
{"role": "assistant", "content": "hi"},
|
| 74 |
+
_assistant_with_tools("tc_1", "tc_2"), # idx=3
|
| 75 |
+
_tool_result("tc_1"), # idx=4
|
| 76 |
+
_tool_result("tc_2"), # idx=5
|
| 77 |
+
{"role": "user", "content": "next"},
|
| 78 |
+
]
|
| 79 |
+
# Boundary at 4, messages[3] = assistant with tool_calls → pull back to 3
|
| 80 |
+
assert comp._align_boundary_backward(messages, 4) == 3
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
# End-to-end: compression must not lose tool results
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
class TestCompressionToolResultPreservation:
|
| 91 |
+
"""Verify that compress() never silently drops tool results."""
|
| 92 |
+
|
| 93 |
+
def test_parallel_tool_results_not_lost(self):
|
| 94 |
+
"""The exact scenario that triggered silent data loss before the fix."""
|
| 95 |
+
comp = _make_compressor(protect_first_n=3, protect_last_n=4)
|
| 96 |
+
|
| 97 |
+
messages = [
|
| 98 |
+
{"role": "system", "content": "You are helpful."}, # 0
|
| 99 |
+
{"role": "user", "content": "Hello"}, # 1
|
| 100 |
+
{"role": "assistant", "content": "Hi there!"}, # 2 (end of head)
|
| 101 |
+
{"role": "user", "content": "Read 7 files for me"}, # 3
|
| 102 |
+
_assistant_with_tools("tc_A", "tc_B", "tc_C", "tc_D", "tc_E", "tc_F", "tc_G"), # 4
|
| 103 |
+
_tool_result("tc_A", "content of file A"), # 5
|
| 104 |
+
_tool_result("tc_B", "content of file B"), # 6
|
| 105 |
+
_tool_result("tc_C", "content of file C"), # 7
|
| 106 |
+
_tool_result("tc_D", "content of file D"), # 8
|
| 107 |
+
_tool_result("tc_E", "content of file E"), # 9
|
| 108 |
+
_tool_result("tc_F", "content of file F"), # 10
|
| 109 |
+
_tool_result("tc_G", "CRITICAL DATA in file G"), # 11 ← compress_end=15-4=11
|
| 110 |
+
{"role": "user", "content": "Now summarize them"}, # 12
|
| 111 |
+
{"role": "assistant", "content": "Here is the summary..."}, # 13
|
| 112 |
+
{"role": "user", "content": "Thanks"}, # 14
|
| 113 |
+
]
|
| 114 |
+
# 15 messages. compress_end = 15 - 4 = 11 (before fix: splits tool group)
|
| 115 |
+
|
| 116 |
+
fake_summary = "[Summary of earlier conversation]"
|
| 117 |
+
with patch.object(comp, "_generate_summary", return_value=fake_summary):
|
| 118 |
+
result = comp.compress(messages, current_tokens=7000)
|
| 119 |
+
|
| 120 |
+
# After compression, no tool results should be orphaned/lost.
|
| 121 |
+
# All tool results in the result must have a matching assistant tool_call.
|
| 122 |
+
assistant_call_ids = set()
|
| 123 |
+
for msg in result:
|
| 124 |
+
if msg.get("role") == "assistant":
|
| 125 |
+
for tc in msg.get("tool_calls") or []:
|
| 126 |
+
cid = tc.get("id", "")
|
| 127 |
+
if cid:
|
| 128 |
+
assistant_call_ids.add(cid)
|
| 129 |
+
|
| 130 |
+
tool_result_ids = set()
|
| 131 |
+
for msg in result:
|
| 132 |
+
if msg.get("role") == "tool":
|
| 133 |
+
cid = msg.get("tool_call_id")
|
| 134 |
+
if cid:
|
| 135 |
+
tool_result_ids.add(cid)
|
| 136 |
+
|
| 137 |
+
# Every tool result must have a parent — no orphans
|
| 138 |
+
orphaned = tool_result_ids - assistant_call_ids
|
| 139 |
+
assert not orphaned, f"Orphaned tool results found (data loss!): {orphaned}"
|
| 140 |
+
|
| 141 |
+
# Every assistant tool_call must have a real result (not a stub)
|
| 142 |
+
for msg in result:
|
| 143 |
+
if msg.get("role") == "tool":
|
| 144 |
+
assert msg["content"] != "[Result from earlier conversation — see context summary above]", \
|
| 145 |
+
f"Stub result found for {msg.get('tool_call_id')} — real result was lost"
|
tests/agent/test_compression_concurrent_fork.py
ADDED
|
@@ -0,0 +1,2403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression: prevent transcript fork when two paths compress the same session_id.
|
| 2 |
+
|
| 3 |
+
Damien's incident (Discord, 2026-05-28): a long Hermes session in a Discord
|
| 4 |
+
gateway hit the compression threshold at the end of a turn. The parent agent
|
| 5 |
+
finished delivering the response and ``conversation_loop.py`` fired
|
| 6 |
+
``_spawn_background_review(...)`` — which builds a forked ``AIAgent`` that
|
| 7 |
+
inherits ``agent.session_id`` (see ``agent/background_review.py``::
|
| 8 |
+
``review_agent.session_id = agent.session_id``). Roughly two seconds later
|
| 9 |
+
a synthetic ``Background process proc_… completed`` event arrived and
|
| 10 |
+
started a fresh turn on the same parent ``session_id`` (still cached in the
|
| 11 |
+
gateway's ``SessionEntry``). Both paths hit preflight compression on the
|
| 12 |
+
same parent transcript and called ``_compress_context`` concurrently. Each
|
| 13 |
+
ended the parent and created its own CHILD session in ``state.db``, both
|
| 14 |
+
parented to the same old id. The gateway's ``SessionEntry`` only caught one
|
| 15 |
+
rotation; the other child became an orphan that silently accumulated writes.
|
| 16 |
+
|
| 17 |
+
Repro shape on Damien's machine:
|
| 18 |
+
|
| 19 |
+
parent 20260527_234659_e65f0e ended_at=set end_reason='compression'
|
| 20 |
+
child 20260528_113619_fc80e1 parent=20260527_234659_e65f0e (in SessionEntry)
|
| 21 |
+
child <orphan> parent=20260527_234659_e65f0e (silent writes)
|
| 22 |
+
|
| 23 |
+
This regression simulates the two concurrent ``compress_context`` calls
|
| 24 |
+
against a shared ``state.db`` and asserts that the per-session compression
|
| 25 |
+
lock added in this PR prevents the orphan child. Without the lock the
|
| 26 |
+
fixture deterministically produces 2 children; with the lock, exactly 1.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import copy
|
| 32 |
+
import inspect
|
| 33 |
+
import json
|
| 34 |
+
import logging
|
| 35 |
+
import os
|
| 36 |
+
import sqlite3
|
| 37 |
+
import threading
|
| 38 |
+
import time
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
from types import SimpleNamespace
|
| 41 |
+
from unittest.mock import MagicMock, patch
|
| 42 |
+
|
| 43 |
+
import pytest
|
| 44 |
+
|
| 45 |
+
from hermes_state import SessionDB
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _build_agent_with_db(
|
| 49 |
+
db: SessionDB,
|
| 50 |
+
session_id: str,
|
| 51 |
+
*,
|
| 52 |
+
stub_compressor: bool = True,
|
| 53 |
+
):
|
| 54 |
+
"""Build an AIAgent that's wired to ``db`` and pinned to ``session_id``."""
|
| 55 |
+
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
|
| 56 |
+
from run_agent import AIAgent
|
| 57 |
+
|
| 58 |
+
agent = AIAgent(
|
| 59 |
+
api_key="test-key",
|
| 60 |
+
base_url="https://openrouter.ai/api/v1",
|
| 61 |
+
model="test/model",
|
| 62 |
+
quiet_mode=True,
|
| 63 |
+
session_db=db,
|
| 64 |
+
session_id=session_id,
|
| 65 |
+
skip_context_files=True,
|
| 66 |
+
skip_memory=True,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Stub the compressor so it returns deterministic output and DOESN'T make
|
| 70 |
+
# an LLM call. Sleep inside compress() so the two threads' rotations
|
| 71 |
+
# actually overlap — without that the OS could happen to serialize them
|
| 72 |
+
# and hide the bug.
|
| 73 |
+
if not stub_compressor:
|
| 74 |
+
return agent
|
| 75 |
+
|
| 76 |
+
compressor = MagicMock()
|
| 77 |
+
|
| 78 |
+
def _compress_with_overlap(*_a, **_kw):
|
| 79 |
+
time.sleep(0.15)
|
| 80 |
+
return [
|
| 81 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
| 82 |
+
{"role": "user", "content": "tail"},
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
compressor.compress.side_effect = _compress_with_overlap
|
| 86 |
+
compressor.compression_count = 1
|
| 87 |
+
compressor.last_prompt_tokens = 0
|
| 88 |
+
compressor.last_completion_tokens = 0
|
| 89 |
+
compressor._last_summary_error = None
|
| 90 |
+
compressor._last_compress_aborted = False
|
| 91 |
+
compressor._last_aux_model_failure_model = None
|
| 92 |
+
compressor._last_aux_model_failure_error = None
|
| 93 |
+
agent.context_compressor = compressor
|
| 94 |
+
# The compressor is a stub — the one-time compression-model feasibility
|
| 95 |
+
# probe would resolve a REAL auxiliary provider (credential pools, live
|
| 96 |
+
# token exchanges over the network on some dev machines). That makes the
|
| 97 |
+
# first _compress_context call in a test nondeterministically slow (>2s)
|
| 98 |
+
# and flakes event-based timing assertions. Mark it done: these tests
|
| 99 |
+
# exercise locking/fencing/rotation, never aux-model feasibility.
|
| 100 |
+
agent._compression_feasibility_checked = True
|
| 101 |
+
# These tests cover the ROTATION fallback path (forking, child sessions,
|
| 102 |
+
# lock contention) — pin in_place=False so they keep exercising it
|
| 103 |
+
# regardless of the global default (which flipped to True in #38763).
|
| 104 |
+
agent.compression_in_place = False
|
| 105 |
+
return agent
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _count_children(db: SessionDB, parent_sid: str) -> int:
|
| 109 |
+
"""Count rows in state.db whose parent_session_id == parent_sid."""
|
| 110 |
+
rows = db._conn.execute(
|
| 111 |
+
"SELECT id FROM sessions WHERE parent_session_id = ?",
|
| 112 |
+
(parent_sid,),
|
| 113 |
+
).fetchall()
|
| 114 |
+
return len(rows)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _live_child_id(db: SessionDB, parent_sid: str) -> str | None:
|
| 118 |
+
"""The single child id of ``parent_sid``, or None when there is none.
|
| 119 |
+
|
| 120 |
+
Fails loudly on more than one child: callers use this to prove the agents
|
| 121 |
+
converged on the winner's session, so a multi-child state is a fork and
|
| 122 |
+
must not be silently reduced to 'the first row'.
|
| 123 |
+
"""
|
| 124 |
+
rows = db._conn.execute(
|
| 125 |
+
"SELECT id FROM sessions WHERE parent_session_id = ?",
|
| 126 |
+
(parent_sid,),
|
| 127 |
+
).fetchall()
|
| 128 |
+
assert len(rows) <= 1, f"expected at most one child of {parent_sid}, got {rows!r}"
|
| 129 |
+
return rows[0][0] if rows else None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _wait_for_touch(touch_calls: list[str], value: str, timeout: float = 1.0) -> None:
|
| 133 |
+
deadline = time.monotonic() + timeout
|
| 134 |
+
while time.monotonic() < deadline:
|
| 135 |
+
if value in touch_calls:
|
| 136 |
+
return
|
| 137 |
+
time.sleep(0.01)
|
| 138 |
+
pytest.fail(f"Timed out waiting for touch activity {value!r}; calls={touch_calls!r}")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_compression_activity_heartbeat_touches_agent_during_long_compress(tmp_path: Path) -> None:
|
| 142 |
+
"""Long compression must refresh agent activity so gateway watchdogs do not fire."""
|
| 143 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 144 |
+
session_id = "HEARTBEAT_TEST"
|
| 145 |
+
db.create_session(session_id, source="test")
|
| 146 |
+
|
| 147 |
+
agent = _build_agent_with_db(db, session_id)
|
| 148 |
+
agent._compression_activity_heartbeat_interval = 0.1
|
| 149 |
+
touch_calls: list[str] = []
|
| 150 |
+
agent._touch_activity = lambda desc, **_kw: touch_calls.append(desc)
|
| 151 |
+
|
| 152 |
+
def _slow_compress(*_a, **_kw):
|
| 153 |
+
_wait_for_touch(touch_calls, "context compression in progress")
|
| 154 |
+
return [
|
| 155 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
| 156 |
+
{"role": "user", "content": "tail"},
|
| 157 |
+
]
|
| 158 |
+
|
| 159 |
+
agent.context_compressor.compress.side_effect = _slow_compress
|
| 160 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 161 |
+
|
| 162 |
+
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 163 |
+
|
| 164 |
+
assert touch_calls[0] == "context compression started"
|
| 165 |
+
assert "context compression in progress" in touch_calls
|
| 166 |
+
assert touch_calls[-1] == "context compression completed"
|
| 167 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_compression_activity_heartbeat_emits_client_status_events(tmp_path: Path) -> None:
|
| 171 |
+
"""The heartbeat must re-emit the compacting status, not just DB touches.
|
| 172 |
+
|
| 173 |
+
Remote transports (e.g. the Android relay app) run idle-progress turn
|
| 174 |
+
watchdogs that ``session.interrupt`` a turn after ~180s with no gateway
|
| 175 |
+
events. Compression is silent on the event stream, so without periodic
|
| 176 |
+
status heartbeats a long compression is killed mid-flight and retriggers
|
| 177 |
+
forever on sessions near the context ceiling.
|
| 178 |
+
"""
|
| 179 |
+
from agent.conversation_compression import (
|
| 180 |
+
COMPACTION_HEARTBEAT_STATUS,
|
| 181 |
+
is_compaction_progress_status,
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 185 |
+
session_id = "HEARTBEAT_STATUS_TEST"
|
| 186 |
+
db.create_session(session_id, source="test")
|
| 187 |
+
|
| 188 |
+
agent = _build_agent_with_db(db, session_id)
|
| 189 |
+
agent._compression_activity_heartbeat_interval = 0.1
|
| 190 |
+
touch_calls: list[str] = []
|
| 191 |
+
agent._touch_activity = lambda desc, **_kw: touch_calls.append(desc)
|
| 192 |
+
status_events: list[tuple[str, str]] = []
|
| 193 |
+
setattr(
|
| 194 |
+
agent,
|
| 195 |
+
"status_callback",
|
| 196 |
+
lambda event, message: status_events.append((event, message)),
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
def _slow_compress(*_a, **_kw):
|
| 200 |
+
_wait_for_touch(touch_calls, "context compression in progress")
|
| 201 |
+
return [
|
| 202 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
| 203 |
+
{"role": "user", "content": "tail"},
|
| 204 |
+
]
|
| 205 |
+
|
| 206 |
+
agent.context_compressor.compress.side_effect = _slow_compress
|
| 207 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 208 |
+
|
| 209 |
+
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 210 |
+
|
| 211 |
+
heartbeats = [e for e in status_events if e[1] == COMPACTION_HEARTBEAT_STATUS]
|
| 212 |
+
assert heartbeats, "no heartbeat status reached the client"
|
| 213 |
+
# Same "lifecycle" key as the other compaction statuses so the TUI gateway
|
| 214 |
+
# re-tags it to kind="compacting" and Telegram edits one bubble in place.
|
| 215 |
+
assert {event for event, _ in heartbeats} == {"lifecycle"}
|
| 216 |
+
assert is_compaction_progress_status(COMPACTION_HEARTBEAT_STATUS)
|
| 217 |
+
# Exactly one routine start line precedes the first heartbeat; the
|
| 218 |
+
# heartbeat no longer re-emits a start of its own (adapters without
|
| 219 |
+
# send_or_update_status would otherwise post two messages).
|
| 220 |
+
assert status_events[0][1] != COMPACTION_HEARTBEAT_STATUS
|
| 221 |
+
# Every heartbeat is a periodic tick: none may precede the first
|
| 222 |
+
# "in progress" DB touch, which is what start() would have produced.
|
| 223 |
+
first_tick_touch = touch_calls.index("context compression in progress")
|
| 224 |
+
assert first_tick_touch >= 1 # "started" touch came first
|
| 225 |
+
assert len(heartbeats) <= touch_calls.count("context compression in progress")
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_compression_heartbeat_is_silent_for_quiet_context_engines(tmp_path: Path) -> None:
|
| 229 |
+
"""A context engine that suppresses the routine start status opens no
|
| 230 |
+
visible compaction phase; the heartbeat must not open one either (there
|
| 231 |
+
would be no terminal edge to close it)."""
|
| 232 |
+
from agent.conversation_compression import COMPACTION_HEARTBEAT_STATUS
|
| 233 |
+
|
| 234 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 235 |
+
session_id = "HEARTBEAT_QUIET_TEST"
|
| 236 |
+
db.create_session(session_id, source="test")
|
| 237 |
+
|
| 238 |
+
agent = _build_agent_with_db(db, session_id)
|
| 239 |
+
agent._compression_activity_heartbeat_interval = 0.1
|
| 240 |
+
touch_calls: list[str] = []
|
| 241 |
+
agent._touch_activity = lambda desc, **_kw: touch_calls.append(desc)
|
| 242 |
+
status_events: list[tuple[str, str]] = []
|
| 243 |
+
setattr(
|
| 244 |
+
agent,
|
| 245 |
+
"status_callback",
|
| 246 |
+
lambda event, message: status_events.append((event, message)),
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
def _slow_compress(*_a, **_kw):
|
| 250 |
+
_wait_for_touch(touch_calls, "context compression in progress")
|
| 251 |
+
return [
|
| 252 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
| 253 |
+
{"role": "user", "content": "tail"},
|
| 254 |
+
]
|
| 255 |
+
|
| 256 |
+
agent.context_compressor.compress.side_effect = _slow_compress
|
| 257 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 258 |
+
|
| 259 |
+
with patch(
|
| 260 |
+
"agent.conversation_compression.automatic_compaction_status_message",
|
| 261 |
+
return_value="",
|
| 262 |
+
):
|
| 263 |
+
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 264 |
+
|
| 265 |
+
assert all(m != COMPACTION_HEARTBEAT_STATUS for _, m in status_events)
|
| 266 |
+
assert "context compression in progress" in touch_calls # DB touches still ran
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def test_lock_contender_preserves_terminal_compaction_lifecycle(tmp_path: Path) -> None:
|
| 270 |
+
"""A lock loser still closes the structured compaction lifecycle.
|
| 271 |
+
|
| 272 |
+
The gateway independently filters this routine notice for chat surfaces
|
| 273 |
+
unless ``compression.progress_notices`` is enabled. The low-level event
|
| 274 |
+
must remain available so the desktop can retire its compaction phase.
|
| 275 |
+
"""
|
| 276 |
+
from agent.conversation_compression import COMPACTION_DONE_STATUS
|
| 277 |
+
|
| 278 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 279 |
+
session_id = "LOCK_CONTENDER_STATUS_TEST"
|
| 280 |
+
db.create_session(session_id, source="discord")
|
| 281 |
+
assert db.try_acquire_compression_lock(session_id, "winner", ttl_seconds=60)
|
| 282 |
+
|
| 283 |
+
agent = _build_agent_with_db(db, session_id)
|
| 284 |
+
status_events: list[tuple[str, str]] = []
|
| 285 |
+
setattr(
|
| 286 |
+
agent,
|
| 287 |
+
"status_callback",
|
| 288 |
+
lambda event, message: status_events.append((event, message)),
|
| 289 |
+
)
|
| 290 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 291 |
+
|
| 292 |
+
returned, _system_prompt = agent._compress_context(
|
| 293 |
+
messages,
|
| 294 |
+
"sys",
|
| 295 |
+
approx_tokens=120_000,
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
assert returned is messages
|
| 299 |
+
assert getattr(agent, "_compression_skipped_due_to_lock", None) == "winner"
|
| 300 |
+
assert status_events.count(("compacted", COMPACTION_DONE_STATUS)) == 1
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def test_failed_session_split_does_not_announce_compaction_complete(tmp_path: Path) -> None:
|
| 304 |
+
"""A failed durable split must not emit a successful completion edge."""
|
| 305 |
+
from agent.conversation_compression import COMPACTION_DONE_STATUS
|
| 306 |
+
|
| 307 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 308 |
+
session_id = "FAILED_SPLIT_STATUS_TEST"
|
| 309 |
+
db.create_session(session_id, source="discord")
|
| 310 |
+
agent = _build_agent_with_db(db, session_id)
|
| 311 |
+
setattr(agent, "compression_in_place", False)
|
| 312 |
+
db.publish_compression_child = MagicMock(side_effect=RuntimeError("split boom"))
|
| 313 |
+
status_events: list[tuple[str, str]] = []
|
| 314 |
+
setattr(
|
| 315 |
+
agent,
|
| 316 |
+
"status_callback",
|
| 317 |
+
lambda event, message: status_events.append((event, message)),
|
| 318 |
+
)
|
| 319 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 320 |
+
|
| 321 |
+
agent._compress_context(
|
| 322 |
+
messages,
|
| 323 |
+
"sys",
|
| 324 |
+
approx_tokens=120_000,
|
| 325 |
+
force=True,
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
db.publish_compression_child.assert_called_once()
|
| 329 |
+
assert ("compacted", COMPACTION_DONE_STATUS) not in status_events
|
| 330 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def test_failed_in_place_split_does_not_announce_compaction_complete(tmp_path: Path) -> None:
|
| 334 |
+
"""An in-place persistence failure must not emit a completion edge."""
|
| 335 |
+
from agent.conversation_compression import COMPACTION_DONE_STATUS
|
| 336 |
+
|
| 337 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 338 |
+
session_id = "FAILED_IN_PLACE_STATUS_TEST"
|
| 339 |
+
db.create_session(session_id, source="discord")
|
| 340 |
+
agent = _build_agent_with_db(db, session_id)
|
| 341 |
+
setattr(agent, "compression_in_place", True)
|
| 342 |
+
db.archive_and_compact = MagicMock(side_effect=RuntimeError("archive boom"))
|
| 343 |
+
status_events: list[tuple[str, str]] = []
|
| 344 |
+
setattr(
|
| 345 |
+
agent,
|
| 346 |
+
"status_callback",
|
| 347 |
+
lambda event, message: status_events.append((event, message)),
|
| 348 |
+
)
|
| 349 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 350 |
+
|
| 351 |
+
agent._compress_context(
|
| 352 |
+
messages,
|
| 353 |
+
"sys",
|
| 354 |
+
approx_tokens=120_000,
|
| 355 |
+
force=True,
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
db.archive_and_compact.assert_called_once()
|
| 359 |
+
assert ("compacted", COMPACTION_DONE_STATUS) not in status_events
|
| 360 |
+
assert getattr(agent, "session_id", None) == session_id
|
| 361 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def test_compression_activity_heartbeat_stops_on_compress_exception(tmp_path: Path) -> None:
|
| 365 |
+
"""Exception paths must stop the heartbeat and release the compression lock."""
|
| 366 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 367 |
+
session_id = "HEARTBEAT_FAIL_TEST"
|
| 368 |
+
db.create_session(session_id, source="test")
|
| 369 |
+
|
| 370 |
+
agent = _build_agent_with_db(db, session_id)
|
| 371 |
+
agent._compression_activity_heartbeat_interval = 0.1
|
| 372 |
+
touch_calls: list[str] = []
|
| 373 |
+
agent._touch_activity = lambda desc, **_kw: touch_calls.append(desc)
|
| 374 |
+
|
| 375 |
+
def _failing_compress(*_a, **_kw):
|
| 376 |
+
_wait_for_touch(touch_calls, "context compression in progress")
|
| 377 |
+
raise RuntimeError("compress boom")
|
| 378 |
+
|
| 379 |
+
agent.context_compressor.compress.side_effect = _failing_compress
|
| 380 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 381 |
+
|
| 382 |
+
with pytest.raises(RuntimeError, match="compress boom"):
|
| 383 |
+
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 384 |
+
|
| 385 |
+
assert touch_calls[0] == "context compression started"
|
| 386 |
+
assert "context compression in progress" in touch_calls
|
| 387 |
+
assert touch_calls[-1] == "context compression failed"
|
| 388 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def test_compression_activity_heartbeat_ignores_touch_errors(tmp_path: Path) -> None:
|
| 392 |
+
"""Activity touch failures must not affect compression success semantics."""
|
| 393 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 394 |
+
session_id = "HEARTBEAT_TOUCH_ERROR_TEST"
|
| 395 |
+
db.create_session(session_id, source="test")
|
| 396 |
+
|
| 397 |
+
agent = _build_agent_with_db(db, session_id)
|
| 398 |
+
agent._compression_activity_heartbeat_interval = 0.1
|
| 399 |
+
agent._touch_activity = lambda _desc, **_kw: (_ for _ in ()).throw(RuntimeError("touch boom"))
|
| 400 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 401 |
+
|
| 402 |
+
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 403 |
+
|
| 404 |
+
assert compressed[0]["content"] == "[CONTEXT COMPACTION] summary"
|
| 405 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def test_compression_activity_heartbeat_strict_signature_fallback_releases_lock(tmp_path: Path) -> None:
|
| 409 |
+
"""Strict compressor signatures still compress while heartbeat cleanup runs.
|
| 410 |
+
|
| 411 |
+
Main inspects the engine signature up front (_supported_compression_kwargs)
|
| 412 |
+
instead of catching TypeError, so a strict-signature engine is invoked
|
| 413 |
+
exactly once with only the kwargs it accepts. The heartbeat (with a
|
| 414 |
+
non-numeric configured interval falling back to the default) must still
|
| 415 |
+
wrap the call and stop cleanly.
|
| 416 |
+
"""
|
| 417 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 418 |
+
session_id = "HEARTBEAT_TYPEERROR_TEST"
|
| 419 |
+
db.create_session(session_id, source="test")
|
| 420 |
+
|
| 421 |
+
agent = _build_agent_with_db(db, session_id)
|
| 422 |
+
agent._compression_activity_heartbeat_interval = "not-a-number"
|
| 423 |
+
touch_calls: list[str] = []
|
| 424 |
+
agent._touch_activity = lambda desc, **_kw: touch_calls.append(desc)
|
| 425 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 426 |
+
|
| 427 |
+
strict_calls: list[int | None] = []
|
| 428 |
+
|
| 429 |
+
def _strict_compress(messages, current_tokens=None):
|
| 430 |
+
strict_calls.append(current_tokens)
|
| 431 |
+
return [
|
| 432 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] strict summary"},
|
| 433 |
+
{"role": "user", "content": "tail"},
|
| 434 |
+
]
|
| 435 |
+
|
| 436 |
+
agent.context_compressor.compress = _strict_compress
|
| 437 |
+
|
| 438 |
+
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 439 |
+
|
| 440 |
+
assert compressed[0]["content"] == "[CONTEXT COMPACTION] strict summary"
|
| 441 |
+
assert touch_calls[0] == "context compression started"
|
| 442 |
+
assert touch_calls[-1] == "context compression completed"
|
| 443 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 444 |
+
assert strict_calls == [120_000]
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def test_compression_activity_heartbeat_nonfinite_interval_falls_back(tmp_path: Path) -> None:
|
| 448 |
+
"""Non-finite heartbeat intervals must not reach Event.wait()."""
|
| 449 |
+
from agent.conversation_compression import _CompressionActivityHeartbeat
|
| 450 |
+
|
| 451 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 452 |
+
session_id = "HEARTBEAT_NONFINITE_INTERVAL_TEST"
|
| 453 |
+
db.create_session(session_id, source="test")
|
| 454 |
+
|
| 455 |
+
agent = _build_agent_with_db(db, session_id)
|
| 456 |
+
touch_calls: list[str] = []
|
| 457 |
+
touch_provenances: list = []
|
| 458 |
+
|
| 459 |
+
def _capture(desc, *, provenance=None, force_persist=False):
|
| 460 |
+
touch_calls.append(desc)
|
| 461 |
+
touch_provenances.append(provenance)
|
| 462 |
+
|
| 463 |
+
agent._touch_activity = _capture
|
| 464 |
+
|
| 465 |
+
heartbeat = _CompressionActivityHeartbeat(agent, interval_seconds=float("inf"))
|
| 466 |
+
|
| 467 |
+
assert heartbeat._interval_seconds == 60.0
|
| 468 |
+
heartbeat.start()
|
| 469 |
+
heartbeat.stop()
|
| 470 |
+
assert touch_calls == ["context compression started", "context compression completed"]
|
| 471 |
+
from agent.session_activity import ActivityProvenance
|
| 472 |
+
|
| 473 |
+
assert touch_provenances == [
|
| 474 |
+
ActivityProvenance.AGENT_COMPRESSION,
|
| 475 |
+
ActivityProvenance.AGENT_COMPRESSION,
|
| 476 |
+
]
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def test_compression_heartbeat_stop_persists_completed_over_in_progress(
|
| 480 |
+
tmp_path: Path,
|
| 481 |
+
) -> None:
|
| 482 |
+
"""/compress is outside run_conversation, so turn-end clear never runs.
|
| 483 |
+
|
| 484 |
+
Heartbeat progress stamps persist to SessionDB; completion is often
|
| 485 |
+
rate-limited out. stop() must force-persist the terminal label so idle
|
| 486 |
+
sessions show "context compression completed", not a stale "in progress".
|
| 487 |
+
"""
|
| 488 |
+
from agent.conversation_compression import _CompressionActivityHeartbeat
|
| 489 |
+
from agent.session_activity import ActivityProvenance
|
| 490 |
+
|
| 491 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 492 |
+
session_id = "HEARTBEAT_PERSIST_COMPLETED_TEST"
|
| 493 |
+
db.create_session(session_id, source="test")
|
| 494 |
+
|
| 495 |
+
agent = _build_agent_with_db(db, session_id)
|
| 496 |
+
# Long interval: only start/stop touch; we inject the progress stamp.
|
| 497 |
+
hb = _CompressionActivityHeartbeat(agent, interval_seconds=3600.0)
|
| 498 |
+
hb.start()
|
| 499 |
+
|
| 500 |
+
agent._session_activity_last_persist_mono = 0.0
|
| 501 |
+
agent._touch_activity(
|
| 502 |
+
"context compression in progress",
|
| 503 |
+
provenance=ActivityProvenance.AGENT_COMPRESSION,
|
| 504 |
+
)
|
| 505 |
+
row = db.get_session(session_id)
|
| 506 |
+
assert row["last_activity_description"] == "context compression in progress"
|
| 507 |
+
assert row["last_activity_provenance"] == ActivityProvenance.AGENT_COMPRESSION.value
|
| 508 |
+
|
| 509 |
+
# Mimic the common case: completion falls inside the 60s persist window.
|
| 510 |
+
agent._session_activity_last_persist_mono = time.monotonic()
|
| 511 |
+
hb.stop("context compression completed")
|
| 512 |
+
|
| 513 |
+
row = db.get_session(session_id)
|
| 514 |
+
assert row["last_activity_description"] == "context compression completed"
|
| 515 |
+
assert row["last_activity_provenance"] == ActivityProvenance.AGENT_COMPRESSION.value
|
| 516 |
+
assert agent._last_activity_desc == "context compression completed"
|
| 517 |
+
assert agent._last_activity_provenance is ActivityProvenance.AGENT_COMPRESSION
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def test_compression_heartbeat_does_not_clobber_timeout_provenance() -> None:
|
| 521 |
+
"""Detached heartbeat/stop must not overwrite a host timeout stamp."""
|
| 522 |
+
from types import SimpleNamespace
|
| 523 |
+
|
| 524 |
+
from agent.conversation_compression import _CompressionActivityHeartbeat
|
| 525 |
+
from agent.session_activity import ActivityProvenance
|
| 526 |
+
|
| 527 |
+
agent = SimpleNamespace(
|
| 528 |
+
_last_activity_provenance=ActivityProvenance.AGENT_COMPRESSION_TIMEOUT,
|
| 529 |
+
_last_activity_desc="context compression timed out",
|
| 530 |
+
touches=[],
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
def _touch(desc, *, provenance=None, force_persist=False):
|
| 534 |
+
agent.touches.append((desc, provenance))
|
| 535 |
+
agent._last_activity_provenance = provenance
|
| 536 |
+
agent._last_activity_desc = desc
|
| 537 |
+
|
| 538 |
+
agent._touch_activity = _touch
|
| 539 |
+
|
| 540 |
+
hb = _CompressionActivityHeartbeat(agent, interval_seconds=60.0)
|
| 541 |
+
hb._touch("context compression in progress")
|
| 542 |
+
hb.stop("context compression completed")
|
| 543 |
+
|
| 544 |
+
assert agent.touches == []
|
| 545 |
+
assert agent._last_activity_provenance is ActivityProvenance.AGENT_COMPRESSION_TIMEOUT
|
| 546 |
+
assert agent._last_activity_desc == "context compression timed out"
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def test_compression_heartbeat_does_not_clobber_cooldown_provenance() -> None:
|
| 550 |
+
"""Cooldown/abort stamps must also survive a late heartbeat stop."""
|
| 551 |
+
from types import SimpleNamespace
|
| 552 |
+
|
| 553 |
+
from agent.conversation_compression import _CompressionActivityHeartbeat
|
| 554 |
+
from agent.session_activity import ActivityProvenance
|
| 555 |
+
|
| 556 |
+
agent = SimpleNamespace(
|
| 557 |
+
_last_activity_provenance=ActivityProvenance.AGENT_COMPRESSION_COOLDOWN,
|
| 558 |
+
_last_activity_desc="compression blocked (cooldown: 30s remaining)",
|
| 559 |
+
touches=[],
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
def _touch(desc, *, provenance=None, force_persist=False):
|
| 563 |
+
agent.touches.append((desc, provenance))
|
| 564 |
+
agent._last_activity_provenance = provenance
|
| 565 |
+
agent._last_activity_desc = desc
|
| 566 |
+
|
| 567 |
+
agent._touch_activity = _touch
|
| 568 |
+
|
| 569 |
+
hb = _CompressionActivityHeartbeat(agent, interval_seconds=60.0)
|
| 570 |
+
hb._touch("context compression in progress")
|
| 571 |
+
hb.stop("context compression failed")
|
| 572 |
+
|
| 573 |
+
assert agent.touches == []
|
| 574 |
+
assert agent._last_activity_provenance is ActivityProvenance.AGENT_COMPRESSION_COOLDOWN
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def test_compression_heartbeat_start_republishes_after_terminal_provenance() -> None:
|
| 578 |
+
"""A new compression episode may overwrite a prior timeout/cooldown stamp."""
|
| 579 |
+
from types import SimpleNamespace
|
| 580 |
+
|
| 581 |
+
from agent.conversation_compression import _CompressionActivityHeartbeat
|
| 582 |
+
from agent.session_activity import ActivityProvenance
|
| 583 |
+
|
| 584 |
+
agent = SimpleNamespace(
|
| 585 |
+
_last_activity_provenance=ActivityProvenance.AGENT_COMPRESSION_TIMEOUT,
|
| 586 |
+
_last_activity_desc="context compression timed out",
|
| 587 |
+
touches=[],
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
def _touch(desc, *, provenance=None, force_persist=False):
|
| 591 |
+
agent.touches.append((desc, provenance))
|
| 592 |
+
agent._last_activity_provenance = provenance
|
| 593 |
+
agent._last_activity_desc = desc
|
| 594 |
+
|
| 595 |
+
agent._touch_activity = _touch
|
| 596 |
+
|
| 597 |
+
hb = _CompressionActivityHeartbeat(agent, interval_seconds=60.0)
|
| 598 |
+
hb.start()
|
| 599 |
+
hb.stop()
|
| 600 |
+
|
| 601 |
+
assert agent.touches[0] == (
|
| 602 |
+
"context compression started",
|
| 603 |
+
ActivityProvenance.AGENT_COMPRESSION,
|
| 604 |
+
)
|
| 605 |
+
assert agent.touches[-1] == (
|
| 606 |
+
"context compression completed",
|
| 607 |
+
ActivityProvenance.AGENT_COMPRESSION,
|
| 608 |
+
)
|
| 609 |
+
assert agent._last_activity_provenance is ActivityProvenance.AGENT_COMPRESSION
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def test_compression_heartbeat_does_not_rearm_after_unknown_provenance() -> None:
|
| 613 |
+
"""After a terminal stamp, UNKNOWN must not re-arm a detached heartbeat."""
|
| 614 |
+
from types import SimpleNamespace
|
| 615 |
+
|
| 616 |
+
from agent.conversation_compression import _CompressionActivityHeartbeat
|
| 617 |
+
from agent.session_activity import ActivityProvenance
|
| 618 |
+
|
| 619 |
+
agent = SimpleNamespace(
|
| 620 |
+
_last_activity_provenance=ActivityProvenance.AGENT_COMPRESSION_TIMEOUT,
|
| 621 |
+
_last_activity_desc="context compression timed out",
|
| 622 |
+
touches=[],
|
| 623 |
+
)
|
| 624 |
+
|
| 625 |
+
def _touch(desc, *, provenance=None, force_persist=False):
|
| 626 |
+
agent.touches.append((desc, provenance))
|
| 627 |
+
agent._last_activity_provenance = provenance
|
| 628 |
+
agent._last_activity_desc = desc
|
| 629 |
+
|
| 630 |
+
agent._touch_activity = _touch
|
| 631 |
+
|
| 632 |
+
hb = _CompressionActivityHeartbeat(agent, interval_seconds=60.0)
|
| 633 |
+
# First tick observes TIMEOUT and latches silent.
|
| 634 |
+
hb._touch("context compression in progress")
|
| 635 |
+
assert hb._suppressed is True
|
| 636 |
+
# Turn continues / ends and clears labels to UNKNOWN — must stay silent.
|
| 637 |
+
agent._last_activity_provenance = ActivityProvenance.UNKNOWN
|
| 638 |
+
agent._last_activity_desc = "calling model"
|
| 639 |
+
hb._touch("context compression in progress")
|
| 640 |
+
hb.stop("context compression completed")
|
| 641 |
+
|
| 642 |
+
assert agent.touches == []
|
| 643 |
+
assert agent._last_activity_provenance is ActivityProvenance.UNKNOWN
|
| 644 |
+
assert agent._last_activity_desc == "calling model"
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
def test_compression_heartbeat_stops_when_commit_fence_cancelled() -> None:
|
| 648 |
+
"""Host fence cancel must silence detached heartbeat refresh and late stop."""
|
| 649 |
+
from types import SimpleNamespace
|
| 650 |
+
|
| 651 |
+
from agent.conversation_compression import (
|
| 652 |
+
CompressionCommitFence,
|
| 653 |
+
_CompressionActivityHeartbeat,
|
| 654 |
+
)
|
| 655 |
+
from agent.session_activity import ActivityProvenance
|
| 656 |
+
|
| 657 |
+
agent = SimpleNamespace(
|
| 658 |
+
_last_activity_provenance=ActivityProvenance.AGENT_COMPRESSION,
|
| 659 |
+
_last_activity_desc="context compression started",
|
| 660 |
+
touches=[],
|
| 661 |
+
)
|
| 662 |
+
|
| 663 |
+
def _touch(desc, *, provenance=None, force_persist=False):
|
| 664 |
+
agent.touches.append((desc, provenance))
|
| 665 |
+
agent._last_activity_provenance = provenance
|
| 666 |
+
agent._last_activity_desc = desc
|
| 667 |
+
|
| 668 |
+
agent._touch_activity = _touch
|
| 669 |
+
|
| 670 |
+
fence = CompressionCommitFence()
|
| 671 |
+
assert fence.cancel_before_commit() is True
|
| 672 |
+
|
| 673 |
+
hb = _CompressionActivityHeartbeat(
|
| 674 |
+
agent, interval_seconds=60.0, commit_fence=fence
|
| 675 |
+
)
|
| 676 |
+
hb._touch("context compression in progress")
|
| 677 |
+
hb.stop("context compression completed")
|
| 678 |
+
|
| 679 |
+
assert agent.touches == []
|
| 680 |
+
assert hb._suppressed is True
|
| 681 |
+
assert agent._last_activity_provenance is ActivityProvenance.AGENT_COMPRESSION
|
| 682 |
+
assert agent._last_activity_desc == "context compression started"
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
def test_concurrent_compression_does_not_fork_session(tmp_path: Path) -> None:
|
| 686 |
+
"""Two AIAgents that share a session_id MUST NOT both rotate it.
|
| 687 |
+
|
| 688 |
+
Without the per-session compression lock this fixture deterministically
|
| 689 |
+
produces 2 child sessions (transcript fork). With the lock at most one
|
| 690 |
+
path rotates: normally exactly 1 canonical child, or — under heavy DB
|
| 691 |
+
write contention that makes the winner's child create_session exhaust its
|
| 692 |
+
retries — 0, because _compress_context safely rolls back to the parent
|
| 693 |
+
instead of orphaning a child. The forbidden outcome is 2+ (the fork).
|
| 694 |
+
"""
|
| 695 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 696 |
+
|
| 697 |
+
parent_sid = "PARENT_TEST_SESSION"
|
| 698 |
+
db.create_session(parent_sid, source="discord")
|
| 699 |
+
|
| 700 |
+
# Two agents on the same session_id, both wired to the same db —
|
| 701 |
+
# mirrors the parent-turn agent + the background-review fork right
|
| 702 |
+
# after a turn ends.
|
| 703 |
+
agent_a = _build_agent_with_db(db, parent_sid)
|
| 704 |
+
agent_b = _build_agent_with_db(db, parent_sid)
|
| 705 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 706 |
+
|
| 707 |
+
def run(agent):
|
| 708 |
+
try:
|
| 709 |
+
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 710 |
+
except Exception:
|
| 711 |
+
# Surface to the test if either raises — should not happen.
|
| 712 |
+
raise
|
| 713 |
+
|
| 714 |
+
t_a = threading.Thread(target=run, args=(agent_a,), name="main_turn")
|
| 715 |
+
t_b = threading.Thread(target=run, args=(agent_b,), name="review_fork")
|
| 716 |
+
t_a.start()
|
| 717 |
+
t_b.start()
|
| 718 |
+
t_a.join(timeout=10)
|
| 719 |
+
t_b.join(timeout=10)
|
| 720 |
+
|
| 721 |
+
# The invariant Damien's incident is about: the parent must NEVER end up
|
| 722 |
+
# with two (or more) children — that is the transcript fork. The lock
|
| 723 |
+
# guarantees only one path rotates.
|
| 724 |
+
#
|
| 725 |
+
# Zero children is also a valid, non-forking outcome: under heavy DB write
|
| 726 |
+
# contention the winner's child ``create_session`` can exhaust its retry
|
| 727 |
+
# budget, and ``_compress_context`` deliberately rolls the live id back to
|
| 728 |
+
# the (still-indexed) parent rather than stranding an orphan child — see
|
| 729 |
+
# the create-failure rollback in agent/conversation_compression.py. That
|
| 730 |
+
# safe rollback leaves 0 children and is correct. So the contract is
|
| 731 |
+
# ``children <= 1``; only ``>= 2`` is the bug. Asserting an exact ``== 1``
|
| 732 |
+
# made this test flaky under the concurrent CI load that triggers the
|
| 733 |
+
# contention rollback (#54465 churn surfaced it).
|
| 734 |
+
n_children = _count_children(db, parent_sid)
|
| 735 |
+
assert n_children <= 1, (
|
| 736 |
+
f"Compression lock failed: parent session has {n_children} children in "
|
| 737 |
+
"state.db (transcript fork). This is Damien's incident shape — see the "
|
| 738 |
+
"test docstring. Two or more children means the lock did not serialize "
|
| 739 |
+
"the concurrent rotations."
|
| 740 |
+
)
|
| 741 |
+
|
| 742 |
+
# Every agent that moved off the parent must have landed on the SAME id.
|
| 743 |
+
# Counting movers is the wrong contract: the loser can legitimately end up
|
| 744 |
+
# on the child too, without rotating anything itself — it takes the lock
|
| 745 |
+
# after the winner released it, sees the parent was already rotated, and
|
| 746 |
+
# _adopt_live_compression_child() points it at the winner's single child
|
| 747 |
+
# (the "compression recovery: stale session=... adopted live child=..."
|
| 748 |
+
# path). That convergence is the fix working, not a fork; the fork is two
|
| 749 |
+
# DIFFERENT live ids. Asserting ``movers <= 1`` failed on that healthy
|
| 750 |
+
# outcome under concurrent load.
|
| 751 |
+
moved = {a.session_id for a in (agent_a, agent_b) if a.session_id != parent_sid}
|
| 752 |
+
assert len(moved) <= 1, (
|
| 753 |
+
f"Expected at most one post-compression session id, got {sorted(moved)}. "
|
| 754 |
+
"Two distinct ids means the lock didn't serialize them (transcript fork)."
|
| 755 |
+
)
|
| 756 |
+
assert len(moved) == n_children, (
|
| 757 |
+
f"Inconsistent state: agents live on {sorted(moved)} but {n_children} "
|
| 758 |
+
"child session(s) exist — rotation and child creation diverged."
|
| 759 |
+
)
|
| 760 |
+
if moved:
|
| 761 |
+
child = _live_child_id(db, parent_sid)
|
| 762 |
+
assert moved == {child}, (
|
| 763 |
+
f"Agents live on {sorted(moved)} but the parent's only child is "
|
| 764 |
+
f"{child} — an agent is writing to a session outside the lineage."
|
| 765 |
+
)
|
| 766 |
+
|
| 767 |
+
# The lock must be released after both paths finished, regardless of
|
| 768 |
+
# whether the winner committed a child or rolled back.
|
| 769 |
+
assert db.get_compression_lock_holder(parent_sid) is None, (
|
| 770 |
+
"Compression lock leaked: still held after both paths completed."
|
| 771 |
+
)
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
def test_durable_message_committed_before_lease_is_adopted(
|
| 775 |
+
tmp_path: Path,
|
| 776 |
+
) -> None:
|
| 777 |
+
"""A durable row absent from the caller snapshot must still be compressed.
|
| 778 |
+
|
| 779 |
+
Previously this path aborted and returned the stale snapshot unchanged,
|
| 780 |
+
which permanently wedged busy sessions: every compress attempt saw the
|
| 781 |
+
DB ahead of the in-memory list, logged "changed before lease
|
| 782 |
+
acquisition", and never called the compressor. Adopting the durable
|
| 783 |
+
transcript keeps the late-committed turn and lets compression proceed.
|
| 784 |
+
"""
|
| 785 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 786 |
+
parent_sid = "PRE_LEASE_DURABLE_RACE"
|
| 787 |
+
db.create_session(parent_sid, source="webui")
|
| 788 |
+
db.append_message(parent_sid, "user", "old durable")
|
| 789 |
+
|
| 790 |
+
# Frontend takes its snapshot, then another producer commits before this
|
| 791 |
+
# compressor acquires the lease.
|
| 792 |
+
stale_snapshot = [{"role": "user", "content": "old durable"}]
|
| 793 |
+
db.append_message(parent_sid, "assistant", "late committed before lease")
|
| 794 |
+
agent = _build_agent_with_db(db, parent_sid)
|
| 795 |
+
|
| 796 |
+
returned, _system_prompt = agent._compress_context(
|
| 797 |
+
stale_snapshot, "sys", approx_tokens=120_000
|
| 798 |
+
)
|
| 799 |
+
|
| 800 |
+
agent.context_compressor.compress.assert_called_once()
|
| 801 |
+
compressed_arg = agent.context_compressor.compress.call_args.args[0]
|
| 802 |
+
assert [m["content"] for m in compressed_arg] == [
|
| 803 |
+
"old durable",
|
| 804 |
+
"late committed before lease",
|
| 805 |
+
]
|
| 806 |
+
# Must not echo the stale snapshot — compression proceeded on the
|
| 807 |
+
# adopted durable transcript (rotation publishes a child session).
|
| 808 |
+
assert returned is not stale_snapshot
|
| 809 |
+
assert returned[0]["content"] == "[CONTEXT COMPACTION] summary"
|
| 810 |
+
assert agent.session_id != parent_sid
|
| 811 |
+
child_id = _live_child_id(db, parent_sid)
|
| 812 |
+
assert child_id is not None
|
| 813 |
+
assert child_id == agent.session_id
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
def test_fence_cancelled_compression_leaves_lock_reacquirable(tmp_path: Path) -> None:
|
| 820 |
+
"""A fence-cancelled attempt must not poison the per-session lock.
|
| 821 |
+
|
| 822 |
+
Lock-release verification for the hygiene-timeout path: after the gateway
|
| 823 |
+
times out and cancels a hygiene compression at the commit fence, the very
|
| 824 |
+
next attempt on the same session (e.g. the user running ``/compress``)
|
| 825 |
+
must acquire the compression lock and commit normally. A leaked lock here
|
| 826 |
+
would silently block every future compaction for the session until TTL
|
| 827 |
+
expiry.
|
| 828 |
+
"""
|
| 829 |
+
from agent.conversation_compression import CompressionCommitFence
|
| 830 |
+
|
| 831 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 832 |
+
session_id = "HYGIENE_LOCK_REACQUIRE"
|
| 833 |
+
db.create_session(session_id, source="telegram")
|
| 834 |
+
|
| 835 |
+
agent = _build_agent_with_db(db, session_id)
|
| 836 |
+
agent.compression_in_place = True
|
| 837 |
+
agent._cached_system_prompt = "sys"
|
| 838 |
+
summary_started = threading.Event()
|
| 839 |
+
release_summary = threading.Event()
|
| 840 |
+
|
| 841 |
+
def _slow_summary(*_args, **_kwargs):
|
| 842 |
+
summary_started.set()
|
| 843 |
+
assert release_summary.wait(timeout=5)
|
| 844 |
+
agent.context_compressor._proactive_prune_rearm_tokens = 0
|
| 845 |
+
return [
|
| 846 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
| 847 |
+
{"role": "user", "content": "tail"},
|
| 848 |
+
]
|
| 849 |
+
|
| 850 |
+
agent.context_compressor.compress.side_effect = _slow_summary
|
| 851 |
+
agent.context_compressor._proactive_prune_rearm_tokens = 120_000
|
| 852 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 853 |
+
fence = CompressionCommitFence()
|
| 854 |
+
result = {}
|
| 855 |
+
|
| 856 |
+
def _run_compression() -> None:
|
| 857 |
+
result["value"] = agent._compress_context(
|
| 858 |
+
messages,
|
| 859 |
+
"sys",
|
| 860 |
+
approx_tokens=120_000,
|
| 861 |
+
commit_fence=fence,
|
| 862 |
+
)
|
| 863 |
+
|
| 864 |
+
worker = threading.Thread(target=_run_compression, name="fenced-hygiene")
|
| 865 |
+
worker.start()
|
| 866 |
+
assert summary_started.wait(timeout=2)
|
| 867 |
+
assert fence.cancel_before_commit() is True
|
| 868 |
+
release_summary.set()
|
| 869 |
+
worker.join(timeout=5)
|
| 870 |
+
assert not worker.is_alive()
|
| 871 |
+
|
| 872 |
+
# Cancelled attempt: no mutation, and — the invariant under test — the
|
| 873 |
+
# per-session compression lock is fully released.
|
| 874 |
+
assert result["value"][0] is messages
|
| 875 |
+
assert agent.context_compressor._proactive_prune_rearm_tokens == 120_000
|
| 876 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 877 |
+
|
| 878 |
+
# The NEXT attempt (no fence — a manual /compress retry) must be able to
|
| 879 |
+
# acquire the lock and commit an in-place compaction normally.
|
| 880 |
+
agent.context_compressor.compress.side_effect = lambda *_a, **_kw: [
|
| 881 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] retry summary"},
|
| 882 |
+
{"role": "user", "content": "tail"},
|
| 883 |
+
]
|
| 884 |
+
retried, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 885 |
+
|
| 886 |
+
assert retried is not messages
|
| 887 |
+
assert len(retried) < len(messages)
|
| 888 |
+
assert agent.session_id == session_id # in-place: same session id
|
| 889 |
+
assert agent._last_compaction_in_place is True
|
| 890 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
def test_commit_fence_waits_for_an_active_commit() -> None:
|
| 894 |
+
"""A timeout that loses the fence race cannot overlap the live turn."""
|
| 895 |
+
from agent.conversation_compression import CompressionCommitFence
|
| 896 |
+
|
| 897 |
+
fence = CompressionCommitFence()
|
| 898 |
+
assert fence.begin_commit() is True
|
| 899 |
+
assert fence.try_cancel_before_commit() is None
|
| 900 |
+
cancel_started = threading.Event()
|
| 901 |
+
cancel_finished = threading.Event()
|
| 902 |
+
result = {}
|
| 903 |
+
|
| 904 |
+
def _cancel() -> None:
|
| 905 |
+
cancel_started.set()
|
| 906 |
+
result["cancelled"] = fence.cancel_before_commit()
|
| 907 |
+
cancel_finished.set()
|
| 908 |
+
|
| 909 |
+
waiter = threading.Thread(target=_cancel, name="hygiene-timeout-fence")
|
| 910 |
+
waiter.start()
|
| 911 |
+
try:
|
| 912 |
+
assert cancel_started.wait(timeout=2)
|
| 913 |
+
assert not cancel_finished.is_set()
|
| 914 |
+
finally:
|
| 915 |
+
fence.finish_commit()
|
| 916 |
+
waiter.join(timeout=2)
|
| 917 |
+
|
| 918 |
+
assert not waiter.is_alive()
|
| 919 |
+
assert result["cancelled"] is False
|
| 920 |
+
|
| 921 |
+
|
| 922 |
+
def test_total_deadline_cancellation_retains_lock_until_worker_cleanup() -> None:
|
| 923 |
+
"""A total-ceiling timeout must exclude retries while its worker is alive."""
|
| 924 |
+
from agent.conversation_compression import CompressionCommitFence
|
| 925 |
+
|
| 926 |
+
released = threading.Event()
|
| 927 |
+
fence = CompressionCommitFence(total_ceiling_seconds=1.0)
|
| 928 |
+
fence.register_cancelled_lock_release(released.set)
|
| 929 |
+
fence.retain_compression_lock_until_worker_done()
|
| 930 |
+
|
| 931 |
+
now = time.monotonic()
|
| 932 |
+
with patch(
|
| 933 |
+
"agent.conversation_compression.time.monotonic",
|
| 934 |
+
return_value=now + 2.0,
|
| 935 |
+
):
|
| 936 |
+
assert fence.is_cancelled
|
| 937 |
+
assert fence.try_cancel_before_commit() is True
|
| 938 |
+
fence.release_cancelled_compression_lock()
|
| 939 |
+
|
| 940 |
+
assert not released.is_set()
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
def test_delayed_contender_adopts_unique_rotated_child(tmp_path: Path) -> None:
|
| 944 |
+
"""A stale agent must continue on the winner's compacted child transcript."""
|
| 945 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 946 |
+
parent_sid = "STALE_PARENT"
|
| 947 |
+
child_sid = "CANONICAL_CHILD"
|
| 948 |
+
db.create_session(parent_sid, source="webui")
|
| 949 |
+
db.end_session(parent_sid, "compression")
|
| 950 |
+
db.create_session(child_sid, source="webui", parent_session_id=parent_sid)
|
| 951 |
+
compacted = [
|
| 952 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
| 953 |
+
{"role": "assistant", "content": "compacted tail"},
|
| 954 |
+
]
|
| 955 |
+
db.replace_messages(child_sid, compacted)
|
| 956 |
+
|
| 957 |
+
agent = _build_agent_with_db(db, parent_sid)
|
| 958 |
+
stale_messages = [
|
| 959 |
+
{"role": "user", "content": "stale"},
|
| 960 |
+
{"role": "assistant", "content": "x" * 1000},
|
| 961 |
+
]
|
| 962 |
+
recovered, _system_prompt = agent._compress_context(
|
| 963 |
+
stale_messages, "sys", approx_tokens=120_000
|
| 964 |
+
)
|
| 965 |
+
|
| 966 |
+
assert agent.session_id == child_sid
|
| 967 |
+
assert [(m["role"], m["content"]) for m in recovered] == [
|
| 968 |
+
("user", "[CONTEXT COMPACTION] summary"),
|
| 969 |
+
("assistant", "compacted tail"),
|
| 970 |
+
]
|
| 971 |
+
assert agent._session_db_created is True
|
| 972 |
+
assert agent._flushed_db_message_session_id == child_sid
|
| 973 |
+
assert agent._last_flushed_db_idx == len(recovered)
|
| 974 |
+
agent.context_compressor.compress.assert_not_called()
|
| 975 |
+
lifecycle_args, lifecycle_kwargs = agent.context_compressor.on_session_start.call_args
|
| 976 |
+
assert lifecycle_args == (child_sid,)
|
| 977 |
+
assert lifecycle_kwargs["boundary_reason"] == "compression"
|
| 978 |
+
assert lifecycle_kwargs["old_session_id"] == parent_sid
|
| 979 |
+
assert lifecycle_kwargs["session_db"] is db
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
|
| 983 |
+
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
|
| 987 |
+
|
| 988 |
+
def _no_consecutive_user_roles(messages: list) -> bool:
|
| 989 |
+
roles = [m.get("role") for m in messages if isinstance(m, dict)]
|
| 990 |
+
return all(
|
| 991 |
+
not (roles[i] == roles[i + 1] == "user") for i in range(len(roles) - 1)
|
| 992 |
+
)
|
| 993 |
+
|
| 994 |
+
|
| 995 |
+
def test_restored_anchor_never_creates_consecutive_user_roles() -> None:
|
| 996 |
+
"""Anchor restoration must preserve strict role alternation (#55677).
|
| 997 |
+
|
| 998 |
+
The original insertion helper could land the human anchor directly next
|
| 999 |
+
to user-role scaffolding (index-0 insert before a leading synthetic user
|
| 1000 |
+
turn, or a bare scaffolding-only transcript), producing user/user
|
| 1001 |
+
adjacency that strict chat templates reject.
|
| 1002 |
+
"""
|
| 1003 |
+
from agent.conversation_compression import _insert_real_user_anchor
|
| 1004 |
+
|
| 1005 |
+
anchor = {"role": "user", "content": "REAL HUMAN ASK"}
|
| 1006 |
+
|
| 1007 |
+
# Leading synthetic user turn before the assistant summary.
|
| 1008 |
+
compressed = [
|
| 1009 |
+
{
|
| 1010 |
+
"role": "user",
|
| 1011 |
+
"content": "[System: Your previous response was truncated ...]",
|
| 1012 |
+
"_empty_recovery_synthetic": True,
|
| 1013 |
+
},
|
| 1014 |
+
{"role": "assistant", "content": "summary"},
|
| 1015 |
+
{
|
| 1016 |
+
"role": "user",
|
| 1017 |
+
"content": "[Your active task list was preserved across context compression]",
|
| 1018 |
+
"_todo_snapshot_synthetic": True,
|
| 1019 |
+
},
|
| 1020 |
+
]
|
| 1021 |
+
_insert_real_user_anchor(compressed, dict(anchor))
|
| 1022 |
+
assert _no_consecutive_user_roles(compressed)
|
| 1023 |
+
assert any(m.get("content", "").startswith("REAL HUMAN ASK") for m in compressed)
|
| 1024 |
+
|
| 1025 |
+
# Scaffolding-only transcript: the anchor is merged, not inserted
|
| 1026 |
+
# adjacent, and the merged turn leads with the human ask.
|
| 1027 |
+
compressed = [
|
| 1028 |
+
{
|
| 1029 |
+
"role": "user",
|
| 1030 |
+
"content": "[Your active task list was preserved across context compression]",
|
| 1031 |
+
"_todo_snapshot_synthetic": True,
|
| 1032 |
+
},
|
| 1033 |
+
]
|
| 1034 |
+
_insert_real_user_anchor(compressed, dict(anchor))
|
| 1035 |
+
assert _no_consecutive_user_roles(compressed)
|
| 1036 |
+
assert len(compressed) == 1
|
| 1037 |
+
assert compressed[0]["content"].startswith("REAL HUMAN ASK")
|
| 1038 |
+
assert not compressed[0].get("_todo_snapshot_synthetic")
|
| 1039 |
+
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
|
| 1043 |
+
def test_compression_persists_child_handoff_immediately(tmp_path: Path) -> None:
|
| 1044 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1045 |
+
parent_sid = "HEADLESS_PREFLIGHT_PARENT"
|
| 1046 |
+
db.create_session(parent_sid, source="cli")
|
| 1047 |
+
|
| 1048 |
+
agent = _build_agent_with_db(db, parent_sid)
|
| 1049 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1050 |
+
|
| 1051 |
+
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 1052 |
+
child_sid = agent.session_id
|
| 1053 |
+
|
| 1054 |
+
assert child_sid != parent_sid
|
| 1055 |
+
assert db.get_session(parent_sid)["end_reason"] == "compression"
|
| 1056 |
+
assert len(db.get_messages(child_sid)) == len(compressed)
|
| 1057 |
+
|
| 1058 |
+
agent._flush_messages_to_session_db(compressed, None)
|
| 1059 |
+
assert len(db.get_messages(child_sid)) == len(compressed)
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
+
|
| 1063 |
+
|
| 1064 |
+
def test_rotation_publish_failure_restores_proactive_prune_runway(
|
| 1065 |
+
tmp_path: Path,
|
| 1066 |
+
) -> None:
|
| 1067 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1068 |
+
parent_sid = "PRUNE_RUNWAY_ROLLBACK_PARENT"
|
| 1069 |
+
db.create_session(
|
| 1070 |
+
parent_sid,
|
| 1071 |
+
source="cli",
|
| 1072 |
+
model_config={"keep": "value", "_proactive_prune_rearm_tokens": 120_000},
|
| 1073 |
+
)
|
| 1074 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1075 |
+
db.append_messages_batch(parent_sid, messages)
|
| 1076 |
+
for message in messages:
|
| 1077 |
+
message["_db_persisted"] = True
|
| 1078 |
+
agent = _build_agent_with_db(db, parent_sid)
|
| 1079 |
+
agent.context_compressor._proactive_prune_rearm_tokens = 120_000
|
| 1080 |
+
|
| 1081 |
+
def _compress(*_args, **_kwargs):
|
| 1082 |
+
agent.context_compressor._proactive_prune_rearm_tokens = 0
|
| 1083 |
+
return [
|
| 1084 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
| 1085 |
+
{"role": "user", "content": "tail"},
|
| 1086 |
+
]
|
| 1087 |
+
|
| 1088 |
+
agent.context_compressor.compress.side_effect = _compress
|
| 1089 |
+
durable_before = db.get_messages_as_conversation(parent_sid)
|
| 1090 |
+
with patch.object(
|
| 1091 |
+
db,
|
| 1092 |
+
"publish_compression_child",
|
| 1093 |
+
side_effect=RuntimeError("publish failed"),
|
| 1094 |
+
):
|
| 1095 |
+
returned, _sp = agent._compress_context(
|
| 1096 |
+
messages, "sys", approx_tokens=120_000,
|
| 1097 |
+
)
|
| 1098 |
+
|
| 1099 |
+
assert returned is messages
|
| 1100 |
+
assert agent.session_id == parent_sid
|
| 1101 |
+
assert agent.context_compressor._proactive_prune_rearm_tokens == 120_000
|
| 1102 |
+
assert db.get_messages_as_conversation(parent_sid) == durable_before
|
| 1103 |
+
assert json.loads(db.get_session(parent_sid)["model_config"]) == {
|
| 1104 |
+
"keep": "value",
|
| 1105 |
+
"_proactive_prune_rearm_tokens": 120_000,
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
|
| 1109 |
+
def test_full_in_place_compression_atomically_clears_durable_prune_runway(
|
| 1110 |
+
tmp_path: Path,
|
| 1111 |
+
) -> None:
|
| 1112 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1113 |
+
session_id = "IN_PLACE_CLEARS_PRUNE_RUNWAY"
|
| 1114 |
+
db.create_session(
|
| 1115 |
+
session_id,
|
| 1116 |
+
source="cli",
|
| 1117 |
+
model_config={"keep": "value", "_proactive_prune_rearm_tokens": 120_000},
|
| 1118 |
+
)
|
| 1119 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1120 |
+
db.append_messages_batch(session_id, messages)
|
| 1121 |
+
agent = _build_agent_with_db(db, session_id)
|
| 1122 |
+
agent.compression_in_place = True
|
| 1123 |
+
agent.context_compressor._proactive_prune_rearm_tokens = 120_000
|
| 1124 |
+
|
| 1125 |
+
compressed, _sp = agent._compress_context(
|
| 1126 |
+
messages, "sys", approx_tokens=120_000,
|
| 1127 |
+
)
|
| 1128 |
+
|
| 1129 |
+
assert agent.session_id == session_id
|
| 1130 |
+
assert [message["content"] for message in db.get_messages_as_conversation(session_id)] == [
|
| 1131 |
+
message["content"] for message in compressed
|
| 1132 |
+
]
|
| 1133 |
+
assert json.loads(db.get_session(session_id)["model_config"]) == {"keep": "value"}
|
| 1134 |
+
|
| 1135 |
+
|
| 1136 |
+
def test_rotation_child_starts_without_durable_prune_runway(tmp_path: Path) -> None:
|
| 1137 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1138 |
+
parent_sid = "ROTATION_CLEARS_PRUNE_RUNWAY"
|
| 1139 |
+
db.create_session(
|
| 1140 |
+
parent_sid,
|
| 1141 |
+
source="cli",
|
| 1142 |
+
model_config={"keep": "parent", "_proactive_prune_rearm_tokens": 120_000},
|
| 1143 |
+
)
|
| 1144 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1145 |
+
db.append_messages_batch(parent_sid, messages)
|
| 1146 |
+
agent = _build_agent_with_db(db, parent_sid)
|
| 1147 |
+
|
| 1148 |
+
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 1149 |
+
|
| 1150 |
+
assert agent.session_id != parent_sid
|
| 1151 |
+
child_config = json.loads(db.get_session(agent.session_id)["model_config"])
|
| 1152 |
+
assert "_proactive_prune_rearm_tokens" not in child_config
|
| 1153 |
+
assert json.loads(db.get_session(parent_sid)["model_config"])[
|
| 1154 |
+
"_proactive_prune_rearm_tokens"
|
| 1155 |
+
] == 120_000
|
| 1156 |
+
|
| 1157 |
+
|
| 1158 |
+
@pytest.mark.parametrize("in_place", [False, True])
|
| 1159 |
+
def test_equal_copy_compression_result_does_not_rewrite_session(
|
| 1160 |
+
tmp_path: Path,
|
| 1161 |
+
in_place: bool,
|
| 1162 |
+
) -> None:
|
| 1163 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1164 |
+
parent_sid = f"EQUAL_COPY_NOOP_{in_place}"
|
| 1165 |
+
db.create_session(parent_sid, source="cli")
|
| 1166 |
+
|
| 1167 |
+
agent = _build_agent_with_db(db, parent_sid)
|
| 1168 |
+
setattr(agent, "compression_in_place", in_place)
|
| 1169 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1170 |
+
compressor = getattr(agent, "context_compressor")
|
| 1171 |
+
compressor.compress.side_effect = lambda incoming, **_kw: list(incoming)
|
| 1172 |
+
|
| 1173 |
+
with patch.object(
|
| 1174 |
+
db,
|
| 1175 |
+
"archive_and_compact",
|
| 1176 |
+
wraps=db.archive_and_compact,
|
| 1177 |
+
) as archive_and_compact:
|
| 1178 |
+
returned, _sp = agent._compress_context(
|
| 1179 |
+
messages,
|
| 1180 |
+
"sys",
|
| 1181 |
+
approx_tokens=120_000,
|
| 1182 |
+
)
|
| 1183 |
+
|
| 1184 |
+
assert returned is messages
|
| 1185 |
+
assert getattr(agent, "session_id") == parent_sid
|
| 1186 |
+
assert _count_children(db, parent_sid) == 0
|
| 1187 |
+
parent = db.get_session(parent_sid)
|
| 1188 |
+
assert parent is not None
|
| 1189 |
+
assert parent["end_reason"] is None
|
| 1190 |
+
assert db.get_compression_lock_holder(parent_sid) is None
|
| 1191 |
+
archive_and_compact.assert_not_called()
|
| 1192 |
+
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
+
|
| 1196 |
+
def test_post_compress_exception_stops_lock_refresher(tmp_path: Path, monkeypatch) -> None:
|
| 1197 |
+
"""A warning-path exception after compress() returns must still release the lock."""
|
| 1198 |
+
real_try_acquire = SessionDB.try_acquire_compression_lock
|
| 1199 |
+
|
| 1200 |
+
def _short_ttl(self, session_id: str, holder: str, ttl_seconds: float = 300.0) -> bool:
|
| 1201 |
+
return real_try_acquire(self, session_id, holder, ttl_seconds=0.15)
|
| 1202 |
+
|
| 1203 |
+
monkeypatch.setattr(SessionDB, "try_acquire_compression_lock", _short_ttl)
|
| 1204 |
+
|
| 1205 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1206 |
+
parent_sid = "REFRESH_EXCEPTION_TEST"
|
| 1207 |
+
db.create_session(parent_sid, source="discord")
|
| 1208 |
+
|
| 1209 |
+
agent = _build_agent_with_db(db, parent_sid)
|
| 1210 |
+
agent._compression_lock_ttl_seconds = 0.15
|
| 1211 |
+
agent._compression_lock_refresh_interval = 0.05
|
| 1212 |
+
agent.context_compressor._last_summary_error = "summary failed"
|
| 1213 |
+
agent._emit_warning = lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("warn boom"))
|
| 1214 |
+
|
| 1215 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1216 |
+
|
| 1217 |
+
with pytest.raises(RuntimeError, match="warn boom"):
|
| 1218 |
+
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 1219 |
+
|
| 1220 |
+
time.sleep(0.25)
|
| 1221 |
+
assert db.try_acquire_compression_lock(parent_sid, "probe", ttl_seconds=1.0) is True
|
| 1222 |
+
|
| 1223 |
+
|
| 1224 |
+
|
| 1225 |
+
|
| 1226 |
+
|
| 1227 |
+
|
| 1228 |
+
|
| 1229 |
+
|
| 1230 |
+
def test_signature_introspection_exception_releases_lock_and_refresher(
|
| 1231 |
+
tmp_path: Path, monkeypatch
|
| 1232 |
+
) -> None:
|
| 1233 |
+
"""Capability inspection failures must not leak the acquired lock lease."""
|
| 1234 |
+
from agent.conversation_compression import (
|
| 1235 |
+
_CompressionLockLeaseRefresher as RealLeaseRefresher,
|
| 1236 |
+
)
|
| 1237 |
+
|
| 1238 |
+
refreshers = []
|
| 1239 |
+
|
| 1240 |
+
class RecordingLeaseRefresher(RealLeaseRefresher):
|
| 1241 |
+
def start(self):
|
| 1242 |
+
refreshers.append(self)
|
| 1243 |
+
return super().start()
|
| 1244 |
+
|
| 1245 |
+
monkeypatch.setattr(
|
| 1246 |
+
"agent.conversation_compression._CompressionLockLeaseRefresher",
|
| 1247 |
+
RecordingLeaseRefresher,
|
| 1248 |
+
)
|
| 1249 |
+
|
| 1250 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1251 |
+
parent_sid = "SIGNATURE_EXCEPTION_TEST"
|
| 1252 |
+
db.create_session(parent_sid, source="discord")
|
| 1253 |
+
|
| 1254 |
+
agent = _build_agent_with_db(db, parent_sid)
|
| 1255 |
+
agent._compression_lock_refresh_interval = 0.1
|
| 1256 |
+
|
| 1257 |
+
class SignatureBomb:
|
| 1258 |
+
calls = 0
|
| 1259 |
+
|
| 1260 |
+
@property
|
| 1261 |
+
def __signature__(self):
|
| 1262 |
+
raise RuntimeError("signature boom")
|
| 1263 |
+
|
| 1264 |
+
def __call__(self, *_args, **_kwargs):
|
| 1265 |
+
self.calls += 1
|
| 1266 |
+
raise AssertionError("engine must not run after signature failure")
|
| 1267 |
+
|
| 1268 |
+
bomb = SignatureBomb()
|
| 1269 |
+
agent.context_compressor.compress = bomb
|
| 1270 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1271 |
+
|
| 1272 |
+
with pytest.raises(RuntimeError, match="signature boom"):
|
| 1273 |
+
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 1274 |
+
|
| 1275 |
+
assert bomb.calls == 0
|
| 1276 |
+
assert db.get_compression_lock_holder(parent_sid) is None
|
| 1277 |
+
assert len(refreshers) == 1
|
| 1278 |
+
assert not refreshers[0]._thread.is_alive()
|
| 1279 |
+
|
| 1280 |
+
|
| 1281 |
+
|
| 1282 |
+
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
|
| 1286 |
+
|
| 1287 |
+
def _make_legacy_session_db_class() -> type:
|
| 1288 |
+
"""Model the class retained in ``sys.modules`` before the lock API existed.
|
| 1289 |
+
|
| 1290 |
+
During the real version-skew incident, a re-imported compression module
|
| 1291 |
+
imports the same still-loaded ``hermes_state`` module, whose ``SessionDB``
|
| 1292 |
+
class is old. The test replaces that module attribute with this lockless
|
| 1293 |
+
class and forwards all persistence operations to a current real database.
|
| 1294 |
+
"""
|
| 1295 |
+
source_path = inspect.getfile(SessionDB)
|
| 1296 |
+
namespace = {"__name__": "hermes_state"}
|
| 1297 |
+
source = '''
|
| 1298 |
+
class SessionDB:
|
| 1299 |
+
def __init__(self, real_db):
|
| 1300 |
+
self._real = real_db
|
| 1301 |
+
|
| 1302 |
+
def __getattribute__(self, name):
|
| 1303 |
+
if name in {"_real", "__class__"}:
|
| 1304 |
+
return object.__getattribute__(self, name)
|
| 1305 |
+
return getattr(object.__getattribute__(self, "_real"), name)
|
| 1306 |
+
'''
|
| 1307 |
+
exec(compile(source, source_path, "exec"), namespace)
|
| 1308 |
+
return namespace["SessionDB"]
|
| 1309 |
+
|
| 1310 |
+
|
| 1311 |
+
class _NominalSessionDBImpostor:
|
| 1312 |
+
"""A proxy that spoofs names but lacks the real SessionDB source contract."""
|
| 1313 |
+
|
| 1314 |
+
def __init__(self, real_db: SessionDB) -> None:
|
| 1315 |
+
self._real = real_db
|
| 1316 |
+
|
| 1317 |
+
def create_session(self, *args, **kwargs):
|
| 1318 |
+
return self._real.create_session(*args, **kwargs)
|
| 1319 |
+
|
| 1320 |
+
def __getattr__(self, name):
|
| 1321 |
+
if name == "try_acquire_compression_lock":
|
| 1322 |
+
raise AttributeError(name)
|
| 1323 |
+
return getattr(self._real, name)
|
| 1324 |
+
|
| 1325 |
+
|
| 1326 |
+
_NominalSessionDBImpostor.__module__ = "hermes_state"
|
| 1327 |
+
_NominalSessionDBImpostor.__name__ = "SessionDB"
|
| 1328 |
+
|
| 1329 |
+
|
| 1330 |
+
class _BrokenLockLookupDB:
|
| 1331 |
+
"""A present lock API whose instance lookup fails unexpectedly."""
|
| 1332 |
+
|
| 1333 |
+
def __init__(self, real_db: SessionDB, error: Exception) -> None:
|
| 1334 |
+
self._real = real_db
|
| 1335 |
+
self._error = error
|
| 1336 |
+
|
| 1337 |
+
def try_acquire_compression_lock(self, *_args, **_kwargs):
|
| 1338 |
+
raise AssertionError("the broken lookup must not resolve to a callable")
|
| 1339 |
+
|
| 1340 |
+
def __getattribute__(self, name):
|
| 1341 |
+
if name == "try_acquire_compression_lock":
|
| 1342 |
+
raise object.__getattribute__(self, "_error")
|
| 1343 |
+
if name in {"_real", "_error", "__class__"}:
|
| 1344 |
+
return object.__getattribute__(self, name)
|
| 1345 |
+
return getattr(object.__getattribute__(self, "_real"), name)
|
| 1346 |
+
|
| 1347 |
+
|
| 1348 |
+
class _NonCallableLockAPI:
|
| 1349 |
+
"""A present lock API descriptor that resolves to a non-callable value."""
|
| 1350 |
+
|
| 1351 |
+
def __init__(self, real_db: SessionDB) -> None:
|
| 1352 |
+
self._real = real_db
|
| 1353 |
+
|
| 1354 |
+
try_acquire_compression_lock = None
|
| 1355 |
+
|
| 1356 |
+
def __getattr__(self, name):
|
| 1357 |
+
return getattr(self._real, name)
|
| 1358 |
+
|
| 1359 |
+
|
| 1360 |
+
|
| 1361 |
+
|
| 1362 |
+
|
| 1363 |
+
|
| 1364 |
+
|
| 1365 |
+
|
| 1366 |
+
|
| 1367 |
+
|
| 1368 |
+
@pytest.mark.parametrize(
|
| 1369 |
+
"error",
|
| 1370 |
+
[
|
| 1371 |
+
RuntimeError("simulated lock-table corruption"),
|
| 1372 |
+
AttributeError("simulated internal lock attribute error"),
|
| 1373 |
+
TypeError("simulated internal lock type error"),
|
| 1374 |
+
],
|
| 1375 |
+
)
|
| 1376 |
+
def test_real_lock_api_internal_errors_fail_closed_skips_compression(
|
| 1377 |
+
tmp_path: Path, monkeypatch, error: Exception
|
| 1378 |
+
) -> None:
|
| 1379 |
+
"""Errors after a real lock API resolves must preserve session lineage.
|
| 1380 |
+
|
| 1381 |
+
``AttributeError`` only means version skew while resolving the method. This
|
| 1382 |
+
test injects failures beneath the real ``SessionDB.try_acquire...`` body,
|
| 1383 |
+
proving that an internal AttributeError or TypeError cannot take the
|
| 1384 |
+
structural-absence compatibility path.
|
| 1385 |
+
"""
|
| 1386 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1387 |
+
parent_sid = "ERRORING_LOCK_TEST"
|
| 1388 |
+
db.create_session(parent_sid, source="discord")
|
| 1389 |
+
|
| 1390 |
+
def _fail_lock_write(_fn):
|
| 1391 |
+
raise error
|
| 1392 |
+
|
| 1393 |
+
monkeypatch.setattr(db, "_execute_write", _fail_lock_write)
|
| 1394 |
+
agent = _build_agent_with_db(db, parent_sid)
|
| 1395 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1396 |
+
|
| 1397 |
+
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 1398 |
+
|
| 1399 |
+
# Skipped: messages returned verbatim, no rotation, compressor never ran.
|
| 1400 |
+
assert compressed is messages or compressed == messages
|
| 1401 |
+
assert agent.session_id == parent_sid
|
| 1402 |
+
assert _count_children(db, parent_sid) == 0
|
| 1403 |
+
agent.context_compressor.compress.assert_not_called()
|
| 1404 |
+
|
| 1405 |
+
|
| 1406 |
+
|
| 1407 |
+
|
| 1408 |
+
def test_review_fork_compacts_oversized_snapshot_in_memory(tmp_path: Path) -> None:
|
| 1409 |
+
"""An oversized review snapshot replays warm on the first request, then
|
| 1410 |
+
compacts in memory before further requests — without mutating the parent.
|
| 1411 |
+
|
| 1412 |
+
Regression for #93057: the fork historically pinned ``compression_enabled =
|
| 1413 |
+
False`` because it shares the parent's session_id (issue #38727). That
|
| 1414 |
+
left the review's private snapshot unbounded — every follow-up request in
|
| 1415 |
+
the review tool loop replayed the whole snapshot (350k-384k input tokens
|
| 1416 |
+
per request in production). The fix detaches the fork's compressor from
|
| 1417 |
+
the parent's SessionDB/session_id and enables in-memory-only compaction.
|
| 1418 |
+
|
| 1419 |
+
This test drives the REAL ``_run_review_in_thread`` + ``run_conversation``
|
| 1420 |
+
with a threshold-crossing snapshot across two provider requests and
|
| 1421 |
+
asserts:
|
| 1422 |
+
• the FIRST request replays the full snapshot untouched (warm
|
| 1423 |
+
prompt-cache parity) — no compaction summary, middle turns present;
|
| 1424 |
+
• compression actually fired before the SECOND request (a real
|
| 1425 |
+
threshold crossing, not just setup-time binding state), and that
|
| 1426 |
+
request carries the compaction summary and none of the middle
|
| 1427 |
+
snapshot turns;
|
| 1428 |
+
• the fork keeps the parent's session_id (prompt-cache parity) but its
|
| 1429 |
+
agent-level AND compressor-level session bindings are detached;
|
| 1430 |
+
• the parent's durable transcript, session row, and child-session graph
|
| 1431 |
+
are byte-for-byte unchanged after the compaction ran.
|
| 1432 |
+
"""
|
| 1433 |
+
import agent.background_review as br
|
| 1434 |
+
|
| 1435 |
+
parent_sid = "REVIEW_FORK_IN_MEMORY_COMPACTION_93057"
|
| 1436 |
+
|
| 1437 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1438 |
+
db.create_session(parent_sid, source="discord")
|
| 1439 |
+
db.append_message(parent_sid, role="user", content="durable parent turn")
|
| 1440 |
+
durable_before = db.get_messages(parent_sid)
|
| 1441 |
+
session_row_before = tuple(
|
| 1442 |
+
db._conn.execute(
|
| 1443 |
+
"SELECT id, parent_session_id, ended_at, end_reason FROM sessions WHERE id = ?",
|
| 1444 |
+
(parent_sid,),
|
| 1445 |
+
).fetchone()
|
| 1446 |
+
)
|
| 1447 |
+
parent = _build_agent_with_db(db, parent_sid)
|
| 1448 |
+
parent._cached_system_prompt = "stable parent prompt"
|
| 1449 |
+
|
| 1450 |
+
snapshot = [
|
| 1451 |
+
{
|
| 1452 |
+
"role": "user" if i % 2 == 0 else "assistant",
|
| 1453 |
+
"content": f"review turn {i} " + "x" * 200,
|
| 1454 |
+
}
|
| 1455 |
+
for i in range(24)
|
| 1456 |
+
]
|
| 1457 |
+
|
| 1458 |
+
captured = {}
|
| 1459 |
+
|
| 1460 |
+
def _tool_response(prompt_tokens: int) -> SimpleNamespace:
|
| 1461 |
+
message = SimpleNamespace(
|
| 1462 |
+
content=None,
|
| 1463 |
+
reasoning_content=None,
|
| 1464 |
+
reasoning=None,
|
| 1465 |
+
tool_calls=[
|
| 1466 |
+
SimpleNamespace(
|
| 1467 |
+
id="call_1",
|
| 1468 |
+
type="function",
|
| 1469 |
+
function=SimpleNamespace(
|
| 1470 |
+
name="web_search", arguments='{"query": "x"}'
|
| 1471 |
+
),
|
| 1472 |
+
)
|
| 1473 |
+
],
|
| 1474 |
+
)
|
| 1475 |
+
return SimpleNamespace(
|
| 1476 |
+
choices=[SimpleNamespace(message=message, finish_reason="tool_calls")],
|
| 1477 |
+
model="test/model",
|
| 1478 |
+
usage=SimpleNamespace(
|
| 1479 |
+
prompt_tokens=prompt_tokens,
|
| 1480 |
+
completion_tokens=1,
|
| 1481 |
+
total_tokens=prompt_tokens + 1,
|
| 1482 |
+
),
|
| 1483 |
+
)
|
| 1484 |
+
|
| 1485 |
+
def _final_response():
|
| 1486 |
+
return SimpleNamespace(
|
| 1487 |
+
choices=[
|
| 1488 |
+
SimpleNamespace(
|
| 1489 |
+
index=0,
|
| 1490 |
+
message=SimpleNamespace(
|
| 1491 |
+
role="assistant",
|
| 1492 |
+
content="review complete",
|
| 1493 |
+
tool_calls=None,
|
| 1494 |
+
reasoning_content=None,
|
| 1495 |
+
),
|
| 1496 |
+
finish_reason="stop",
|
| 1497 |
+
)
|
| 1498 |
+
],
|
| 1499 |
+
usage=SimpleNamespace(
|
| 1500 |
+
prompt_tokens=100, completion_tokens=10, total_tokens=110
|
| 1501 |
+
),
|
| 1502 |
+
model="test/model",
|
| 1503 |
+
)
|
| 1504 |
+
|
| 1505 |
+
# The worker does a local ``from run_agent import AIAgent``; patching the
|
| 1506 |
+
# class method covers that import path.
|
| 1507 |
+
from run_agent import AIAgent
|
| 1508 |
+
|
| 1509 |
+
real_run_conversation = AIAgent.run_conversation
|
| 1510 |
+
|
| 1511 |
+
def _run_threshold_crossing_review(self, *args, **kwargs):
|
| 1512 |
+
captured["compression_enabled"] = self.compression_enabled
|
| 1513 |
+
captured["compression_in_place"] = self.compression_in_place
|
| 1514 |
+
captured["session_id"] = self.session_id
|
| 1515 |
+
captured["session_db"] = self._session_db
|
| 1516 |
+
captured["input_budget"] = getattr(
|
| 1517 |
+
self, "_review_input_token_budget", "missing"
|
| 1518 |
+
)
|
| 1519 |
+
captured["defer_first_request"] = getattr(
|
| 1520 |
+
self, "_review_defer_compaction_before_first_response", "missing"
|
| 1521 |
+
)
|
| 1522 |
+
captured["compressor_session_db"] = getattr(
|
| 1523 |
+
self.context_compressor, "_session_db", "missing"
|
| 1524 |
+
)
|
| 1525 |
+
captured["compressor_session_id"] = getattr(
|
| 1526 |
+
self.context_compressor, "_session_id", "missing"
|
| 1527 |
+
)
|
| 1528 |
+
# Stub the fork's compressor so compaction output is deterministic
|
| 1529 |
+
# and no aux-LLM call happens; the trigger/commit paths stay real.
|
| 1530 |
+
self.context_compressor.threshold_tokens = 1
|
| 1531 |
+
self.context_compressor.protect_first_n = 1
|
| 1532 |
+
self.context_compressor.protect_last_n = 1
|
| 1533 |
+
self.context_compressor.compress = MagicMock(
|
| 1534 |
+
return_value=[
|
| 1535 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] review summary"},
|
| 1536 |
+
{"role": "assistant", "content": "summary acknowledged"},
|
| 1537 |
+
]
|
| 1538 |
+
)
|
| 1539 |
+
# Compress on the first pressure check after the first response, then
|
| 1540 |
+
# stand down so the compacted request proceeds instead of looping.
|
| 1541 |
+
_should_compress_calls = {"count": 0}
|
| 1542 |
+
|
| 1543 |
+
def _should_compress(_tokens):
|
| 1544 |
+
_should_compress_calls["count"] += 1
|
| 1545 |
+
return _should_compress_calls["count"] == 1
|
| 1546 |
+
|
| 1547 |
+
self.context_compressor.should_compress = MagicMock(
|
| 1548 |
+
side_effect=_should_compress
|
| 1549 |
+
)
|
| 1550 |
+
self.context_compressor.should_compress_info = MagicMock(
|
| 1551 |
+
return_value=(True, "over threshold")
|
| 1552 |
+
)
|
| 1553 |
+
self.context_compressor.should_compress_preflight = MagicMock(
|
| 1554 |
+
return_value=True
|
| 1555 |
+
)
|
| 1556 |
+
self.context_compressor.should_defer_preflight_to_real_usage = MagicMock(
|
| 1557 |
+
return_value=False
|
| 1558 |
+
)
|
| 1559 |
+
self.context_compressor.get_active_compression_failure_cooldown = MagicMock(
|
| 1560 |
+
return_value=None
|
| 1561 |
+
)
|
| 1562 |
+
self.context_compressor.select_context = MagicMock(return_value=None)
|
| 1563 |
+
self._compression_feasibility_checked = True
|
| 1564 |
+
self.client = MagicMock()
|
| 1565 |
+
self.client.chat.completions.create.side_effect = [
|
| 1566 |
+
_tool_response(100),
|
| 1567 |
+
_final_response(),
|
| 1568 |
+
]
|
| 1569 |
+
self._disable_streaming = True
|
| 1570 |
+
self._use_prompt_caching = False
|
| 1571 |
+
|
| 1572 |
+
def _fake_execute_tool_calls(assistant_message, messages, *_args):
|
| 1573 |
+
tool_call = assistant_message.tool_calls[0]
|
| 1574 |
+
messages.append(
|
| 1575 |
+
{
|
| 1576 |
+
"role": "tool",
|
| 1577 |
+
"name": tool_call.function.name,
|
| 1578 |
+
"tool_call_id": tool_call.id,
|
| 1579 |
+
"content": "ok",
|
| 1580 |
+
}
|
| 1581 |
+
)
|
| 1582 |
+
|
| 1583 |
+
self._execute_tool_calls = _fake_execute_tool_calls
|
| 1584 |
+
|
| 1585 |
+
result = real_run_conversation(self, *args, **kwargs)
|
| 1586 |
+
captured["compression_calls"] = self.context_compressor.compress.call_count
|
| 1587 |
+
create = self.client.chat.completions.create
|
| 1588 |
+
captured["create_calls"] = create.call_count
|
| 1589 |
+
captured["outbound"] = [
|
| 1590 |
+
call.kwargs.get("messages") for call in create.call_args_list
|
| 1591 |
+
]
|
| 1592 |
+
return result
|
| 1593 |
+
|
| 1594 |
+
try:
|
| 1595 |
+
with patch.object(AIAgent, "run_conversation", _run_threshold_crossing_review):
|
| 1596 |
+
br._run_review_in_thread(parent, snapshot, "review this conversation")
|
| 1597 |
+
|
| 1598 |
+
assert captured["compression_calls"] >= 1, (
|
| 1599 |
+
"FIX REGRESSION: the review fork did not compact its oversized "
|
| 1600 |
+
"snapshot. compression_enabled was historically set False on the "
|
| 1601 |
+
"fork, which removed the only bound on the replayed snapshot "
|
| 1602 |
+
"(issue #93057)."
|
| 1603 |
+
)
|
| 1604 |
+
assert captured["create_calls"] == 2, (
|
| 1605 |
+
f"expected a 2-request review (tool call + final), "
|
| 1606 |
+
f"got {captured['create_calls']}"
|
| 1607 |
+
)
|
| 1608 |
+
first_outbound, second_outbound = captured["outbound"]
|
| 1609 |
+
first_contents = [str(m.get("content", "")) for m in first_outbound]
|
| 1610 |
+
second_contents = [str(m.get("content", "")) for m in second_outbound]
|
| 1611 |
+
# Warm-cache parity: the first request replays the full snapshot
|
| 1612 |
+
# untouched — middle turns present, no compaction summary yet.
|
| 1613 |
+
assert any("review turn 12" in text for text in first_contents), (
|
| 1614 |
+
"the review fork's FIRST request must replay the full snapshot "
|
| 1615 |
+
"(warm prompt-cache read) — compaction must not rewrite it before "
|
| 1616 |
+
"the first provider call"
|
| 1617 |
+
)
|
| 1618 |
+
assert not any(
|
| 1619 |
+
"[CONTEXT COMPACTION]" in text for text in first_contents
|
| 1620 |
+
), f"first request was compacted prematurely: {first_contents!r}"
|
| 1621 |
+
# The SECOND request carries the compaction summary and none of the
|
| 1622 |
+
# middle snapshot turns.
|
| 1623 |
+
assert any(
|
| 1624 |
+
"[CONTEXT COMPACTION] review summary" in text
|
| 1625 |
+
for text in second_contents
|
| 1626 |
+
), f"outbound request did not contain the compaction summary: {second_contents!r}"
|
| 1627 |
+
assert not any("review turn 12" in text for text in second_contents), (
|
| 1628 |
+
"outbound request still replays the middle of the snapshot — "
|
| 1629 |
+
"the review replayed an unbounded transcript despite compaction"
|
| 1630 |
+
)
|
| 1631 |
+
assert captured["session_id"] == parent_sid, (
|
| 1632 |
+
"Review fork should inherit the parent's session_id for "
|
| 1633 |
+
"prompt-cache parity."
|
| 1634 |
+
)
|
| 1635 |
+
assert captured["session_db"] is None, (
|
| 1636 |
+
"Review fork must keep its agent-level SessionDB detached "
|
| 1637 |
+
"(_session_db=None) so compaction can never persist."
|
| 1638 |
+
)
|
| 1639 |
+
assert captured["compressor_session_db"] is None, (
|
| 1640 |
+
"Review fork's compressor must be detached from the parent "
|
| 1641 |
+
"SessionDB, otherwise durable cooldown/streak writes land on "
|
| 1642 |
+
"the parent's row (issue #93057)."
|
| 1643 |
+
)
|
| 1644 |
+
assert captured["compressor_session_id"] == ""
|
| 1645 |
+
assert captured["compression_enabled"] is True
|
| 1646 |
+
assert captured["compression_in_place"] is True
|
| 1647 |
+
assert captured["defer_first_request"] is True
|
| 1648 |
+
assert isinstance(captured["input_budget"], int) and captured["input_budget"] > 0
|
| 1649 |
+
|
| 1650 |
+
# Parent session must be byte-for-byte unchanged after the review
|
| 1651 |
+
# compacted its private snapshot.
|
| 1652 |
+
assert parent.session_id == parent_sid
|
| 1653 |
+
assert db.get_messages(parent_sid) == durable_before
|
| 1654 |
+
session_row_after = tuple(
|
| 1655 |
+
db._conn.execute(
|
| 1656 |
+
"SELECT id, parent_session_id, ended_at, end_reason FROM sessions WHERE id = ?",
|
| 1657 |
+
(parent_sid,),
|
| 1658 |
+
).fetchone()
|
| 1659 |
+
)
|
| 1660 |
+
assert session_row_after == session_row_before
|
| 1661 |
+
assert _count_children(db, parent_sid) == 0
|
| 1662 |
+
finally:
|
| 1663 |
+
db.close()
|
| 1664 |
+
|
| 1665 |
+
|
| 1666 |
+
def test_review_fork_fails_closed_when_compressor_rebind_raises(
|
| 1667 |
+
tmp_path: Path, caplog
|
| 1668 |
+
) -> None:
|
| 1669 |
+
"""A failed compressor detachment must keep the fork's compression OFF.
|
| 1670 |
+
|
| 1671 |
+
Regression for the #93057 adversarial review: if ``bind_session_state``
|
| 1672 |
+
cannot sever the engine's binding to the parent's SessionDB/session_id,
|
| 1673 |
+
enabling compression would run it against the parent's live session
|
| 1674 |
+
binding — durable cooldown/streak/ineffective-count writes on the
|
| 1675 |
+
parent's row and the sibling-session race behind #38727 re-opened. The
|
| 1676 |
+
fork must fail CLOSED: keep the historical ``compression_enabled = False``
|
| 1677 |
+
behavior and warn. The review still runs (the iteration cap and the
|
| 1678 |
+
aggregate input budget still bound it).
|
| 1679 |
+
"""
|
| 1680 |
+
import agent.background_review as br
|
| 1681 |
+
from agent.context_compressor import ContextCompressor
|
| 1682 |
+
|
| 1683 |
+
parent_sid = "REVIEW_FORK_REBIND_FAIL_CLOSED_93057"
|
| 1684 |
+
|
| 1685 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1686 |
+
db.create_session(parent_sid, source="discord")
|
| 1687 |
+
parent = _build_agent_with_db(db, parent_sid)
|
| 1688 |
+
parent._cached_system_prompt = "stable parent prompt"
|
| 1689 |
+
|
| 1690 |
+
snapshot = [
|
| 1691 |
+
{
|
| 1692 |
+
"role": "user" if i % 2 == 0 else "assistant",
|
| 1693 |
+
"content": f"review turn {i}",
|
| 1694 |
+
}
|
| 1695 |
+
for i in range(8)
|
| 1696 |
+
]
|
| 1697 |
+
|
| 1698 |
+
captured = {}
|
| 1699 |
+
|
| 1700 |
+
def _capture_fork_flags(self, *args, **kwargs):
|
| 1701 |
+
captured["compression_enabled"] = self.compression_enabled
|
| 1702 |
+
captured["compression_in_place"] = self.compression_in_place
|
| 1703 |
+
captured["input_budget"] = getattr(
|
| 1704 |
+
self, "_review_input_token_budget", "missing"
|
| 1705 |
+
)
|
| 1706 |
+
return {
|
| 1707 |
+
"completed": True,
|
| 1708 |
+
"final_response": "review complete",
|
| 1709 |
+
"api_call_count": 0,
|
| 1710 |
+
}
|
| 1711 |
+
|
| 1712 |
+
# The worker does a local ``from run_agent import AIAgent``; patching the
|
| 1713 |
+
# class method covers that import path.
|
| 1714 |
+
from run_agent import AIAgent
|
| 1715 |
+
|
| 1716 |
+
_real_bind = ContextCompressor.bind_session_state
|
| 1717 |
+
|
| 1718 |
+
def _failing_bind(self, session_db=None, session_id=""):
|
| 1719 |
+
# Only the detachment rebind may fail; any other binding passes
|
| 1720 |
+
# through so the fork's construction path stays intact.
|
| 1721 |
+
if session_db is None:
|
| 1722 |
+
raise RuntimeError("detachment boom")
|
| 1723 |
+
return _real_bind(self, session_db, session_id)
|
| 1724 |
+
|
| 1725 |
+
try:
|
| 1726 |
+
with (
|
| 1727 |
+
patch.object(AIAgent, "run_conversation", _capture_fork_flags),
|
| 1728 |
+
patch.object(
|
| 1729 |
+
ContextCompressor, "bind_session_state", _failing_bind
|
| 1730 |
+
),
|
| 1731 |
+
):
|
| 1732 |
+
with caplog.at_level(logging.WARNING, logger="agent.background_review"):
|
| 1733 |
+
br._run_review_in_thread(parent, snapshot, "review this conversation")
|
| 1734 |
+
|
| 1735 |
+
assert captured["compression_enabled"] is False, (
|
| 1736 |
+
"FIX REGRESSION: a failed compressor rebind must leave "
|
| 1737 |
+
"compression_enabled False on the review fork (fail-closed). "
|
| 1738 |
+
"Enabling compression with the engine still bound to the "
|
| 1739 |
+
"parent's session re-opens the #38727 race (issue #93057)."
|
| 1740 |
+
)
|
| 1741 |
+
assert any(
|
| 1742 |
+
"detachment failed" in record.message for record in caplog.records
|
| 1743 |
+
), (
|
| 1744 |
+
"the failed rebind must log a warning so operators can see the "
|
| 1745 |
+
"fork fell back to the pre-fix behavior"
|
| 1746 |
+
)
|
| 1747 |
+
assert (
|
| 1748 |
+
isinstance(captured["input_budget"], int) and captured["input_budget"] > 0
|
| 1749 |
+
), (
|
| 1750 |
+
"the aggregate input budget must still be armed on the fail-closed "
|
| 1751 |
+
"path — it bounds the review even when compaction stays off"
|
| 1752 |
+
)
|
| 1753 |
+
finally:
|
| 1754 |
+
db.close()
|
| 1755 |
+
|
| 1756 |
+
|
| 1757 |
+
# ── Lease-refresher bounded-failure tolerance (salvage follow-up, #54465) ────
|
| 1758 |
+
# A single falsy refresh (transient DB blip) must NOT permanently kill the
|
| 1759 |
+
# lease — only a *persistent* failure (genuine lost-ownership) should stop the
|
| 1760 |
+
# refresher after a bounded number of consecutive failures. Without this, one
|
| 1761 |
+
# escaped lock-contention error silently reintroduces the TTL-expiry wedge the
|
| 1762 |
+
# PR set out to fix.
|
| 1763 |
+
|
| 1764 |
+
|
| 1765 |
+
class _FlakyRefreshDB:
|
| 1766 |
+
"""A db whose refresh_compression_lock returns a scripted sequence."""
|
| 1767 |
+
|
| 1768 |
+
def __init__(self, results):
|
| 1769 |
+
self._results = list(results)
|
| 1770 |
+
self.calls = 0
|
| 1771 |
+
|
| 1772 |
+
def refresh_compression_lock(self, session_id, holder, ttl_seconds=300.0):
|
| 1773 |
+
self.calls += 1
|
| 1774 |
+
if self._results:
|
| 1775 |
+
return self._results.pop(0)
|
| 1776 |
+
return True # steady-state success after the scripted prefix
|
| 1777 |
+
|
| 1778 |
+
|
| 1779 |
+
def _no_sleep(refresher) -> None:
|
| 1780 |
+
"""Make the refresher loop iterate without real wall-clock sleeps.
|
| 1781 |
+
|
| 1782 |
+
``_stop.wait(interval)`` returns False (keep looping) instantly instead of
|
| 1783 |
+
blocking for the (clamped) interval, so count-based tests stay fast and
|
| 1784 |
+
deterministic — the loop's termination is driven by the failure cap / the
|
| 1785 |
+
scripted db, not by timing.
|
| 1786 |
+
"""
|
| 1787 |
+
refresher._stop.wait = lambda _interval: False # type: ignore[assignment]
|
| 1788 |
+
|
| 1789 |
+
|
| 1790 |
+
|
| 1791 |
+
|
| 1792 |
+
|
| 1793 |
+
|
| 1794 |
+
|
| 1795 |
+
|
| 1796 |
+
def test_lease_refresher_failure_window_is_bounded_by_ttl() -> None:
|
| 1797 |
+
"""Persistent failure stops within one lease's worth of time, not forever.
|
| 1798 |
+
|
| 1799 |
+
The contract (not a magic count): the give-up window
|
| 1800 |
+
``cap * refresh_interval`` must be <= the TTL, so a stuck refresher can
|
| 1801 |
+
never hold the lock past its TTL. We assert that relationship directly
|
| 1802 |
+
rather than freezing a literal cap (behavior contract over snapshot).
|
| 1803 |
+
"""
|
| 1804 |
+
from agent.conversation_compression import _CompressionLockLeaseRefresher
|
| 1805 |
+
|
| 1806 |
+
ttl, interval = 10.0, 2.0 # cap should be int(10/2) = 5
|
| 1807 |
+
db = _FlakyRefreshDB([False] * 50) # never recovers (lost ownership)
|
| 1808 |
+
refresher = _CompressionLockLeaseRefresher(
|
| 1809 |
+
db, "sess", "holder", ttl_seconds=ttl, refresh_interval_seconds=interval
|
| 1810 |
+
)
|
| 1811 |
+
_no_sleep(refresher)
|
| 1812 |
+
refresher._run()
|
| 1813 |
+
|
| 1814 |
+
cap = refresher._max_consecutive_failures
|
| 1815 |
+
assert cap == int(ttl / interval), "cap must derive from ttl/interval"
|
| 1816 |
+
# Stops at the cap — not on the first failure, not forever.
|
| 1817 |
+
assert db.calls == cap
|
| 1818 |
+
# The invariant that makes the cap honest: total tolerance <= one TTL.
|
| 1819 |
+
assert cap * interval <= ttl, (
|
| 1820 |
+
f"give-up window {cap * interval}s must not exceed the lease TTL {ttl}s"
|
| 1821 |
+
)
|
| 1822 |
+
|
| 1823 |
+
|
| 1824 |
+
def test_hard_interrupt_aborts_compression_and_unblocks_session_writes(tmp_path: Path) -> None:
|
| 1825 |
+
"""Ctrl+C must abort an interrupt-protected summary without leaving the
|
| 1826 |
+
session write-blocked behind its compression lease."""
|
| 1827 |
+
from agent import auxiliary_client as aux
|
| 1828 |
+
|
| 1829 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1830 |
+
session_id = "HARD_INTERRUPT_COMPRESSION_TEST"
|
| 1831 |
+
db.create_session(session_id, source="cli")
|
| 1832 |
+
|
| 1833 |
+
agent = _build_agent_with_db(db, session_id)
|
| 1834 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1835 |
+
original_messages = copy.deepcopy(messages)
|
| 1836 |
+
|
| 1837 |
+
def _cancelled_compress(current, *_args, **_kwargs):
|
| 1838 |
+
agent._hard_interrupt_requested.set()
|
| 1839 |
+
assert aux._aux_interrupt_cancel_requested() is True
|
| 1840 |
+
# F3 isolation: the engine only ever sees the pooled snapshot, so an
|
| 1841 |
+
# in-place mutation lands on ``current`` (the worker's copy), never on
|
| 1842 |
+
# the caller's live list. The rollback contract is that the RETURNED
|
| 1843 |
+
# transcript equals the pre-compression one.
|
| 1844 |
+
current[0]["content"] = "must be rolled back"
|
| 1845 |
+
raise aux.AuxiliaryExplicitCancellation()
|
| 1846 |
+
|
| 1847 |
+
agent.context_compressor.compress.side_effect = _cancelled_compress
|
| 1848 |
+
|
| 1849 |
+
compressed, _prompt = agent._compress_context(
|
| 1850 |
+
messages, "sys", approx_tokens=120_000
|
| 1851 |
+
)
|
| 1852 |
+
|
| 1853 |
+
assert compressed == original_messages
|
| 1854 |
+
assert messages == original_messages
|
| 1855 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 1856 |
+
db.append_message(session_id, "assistant", "writes recovered")
|
| 1857 |
+
|
| 1858 |
+
|
| 1859 |
+
def test_late_hard_interrupt_restores_full_compressor_attempt_state_and_retry(
|
| 1860 |
+
tmp_path: Path,
|
| 1861 |
+
) -> None:
|
| 1862 |
+
"""A stop after provider success but before compress() returns is a true no-op."""
|
| 1863 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1864 |
+
session_id = "LATE_HARD_INTERRUPT_STATE_TEST"
|
| 1865 |
+
db.create_session(session_id, source="cli")
|
| 1866 |
+
agent = _build_agent_with_db(db, session_id)
|
| 1867 |
+
agent.compression_in_place = True
|
| 1868 |
+
agent._cached_system_prompt = "sys"
|
| 1869 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1870 |
+
provider_returned = threading.Event()
|
| 1871 |
+
allow_compress_return = threading.Event()
|
| 1872 |
+
shared_telemetry = {"shared": [1, 2, 3]}
|
| 1873 |
+
state_fields = {
|
| 1874 |
+
"_previous_summary": "old-summary",
|
| 1875 |
+
"_summary_has_user_turn": False,
|
| 1876 |
+
"compression_count": 4,
|
| 1877 |
+
"_last_compression_savings_pct": 37.5,
|
| 1878 |
+
"_ineffective_compression_count": 1,
|
| 1879 |
+
"_anti_thrash_recovery_deadline": 123.0,
|
| 1880 |
+
"_fallback_compression_streak": 1,
|
| 1881 |
+
"_verify_compaction_cleared_threshold": False,
|
| 1882 |
+
"_last_compression_made_progress": False,
|
| 1883 |
+
"_summary_failure_cooldown_until": 456.0,
|
| 1884 |
+
"_cooldown_persist_failed": True,
|
| 1885 |
+
"_last_summary_error": "old-error",
|
| 1886 |
+
"_consecutive_timeout_failures": 2,
|
| 1887 |
+
"_last_summary_dropped_count": 3,
|
| 1888 |
+
"_last_summary_fallback_used": True,
|
| 1889 |
+
"_last_compress_aborted": False,
|
| 1890 |
+
"_last_summary_auth_failure": True,
|
| 1891 |
+
"_last_summary_network_failure": True,
|
| 1892 |
+
"_last_aux_model_failure_error": "old-aux-error",
|
| 1893 |
+
"_last_aux_model_failure_model": "old-aux-model",
|
| 1894 |
+
"_summary_model_fallen_back": True,
|
| 1895 |
+
"summary_model": "old-summary-model",
|
| 1896 |
+
"_last_compression_telemetry": shared_telemetry,
|
| 1897 |
+
"_active_compression_telemetry": shared_telemetry,
|
| 1898 |
+
"_compression_telemetry_seed": {"seed": [3]},
|
| 1899 |
+
}
|
| 1900 |
+
for name, value in state_fields.items():
|
| 1901 |
+
setattr(agent.context_compressor, name, copy.deepcopy(value))
|
| 1902 |
+
restored_shared_telemetry = copy.deepcopy(shared_telemetry)
|
| 1903 |
+
agent.context_compressor._last_compression_telemetry = restored_shared_telemetry
|
| 1904 |
+
agent.context_compressor._active_compression_telemetry = restored_shared_telemetry
|
| 1905 |
+
|
| 1906 |
+
def _provider_succeeded_then_waits(*_args, **_kwargs):
|
| 1907 |
+
for name in state_fields:
|
| 1908 |
+
setattr(agent.context_compressor, name, f"mutated-{name}")
|
| 1909 |
+
provider_returned.set()
|
| 1910 |
+
assert allow_compress_return.wait(timeout=5)
|
| 1911 |
+
return [
|
| 1912 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] cancelled summary"},
|
| 1913 |
+
{"role": "user", "content": "tail"},
|
| 1914 |
+
]
|
| 1915 |
+
|
| 1916 |
+
agent.context_compressor.compress.side_effect = _provider_succeeded_then_waits
|
| 1917 |
+
result: dict[str, tuple] = {}
|
| 1918 |
+
worker = threading.Thread(
|
| 1919 |
+
target=lambda: result.setdefault(
|
| 1920 |
+
"value", agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 1921 |
+
),
|
| 1922 |
+
daemon=True,
|
| 1923 |
+
)
|
| 1924 |
+
worker.start()
|
| 1925 |
+
assert provider_returned.wait(timeout=2)
|
| 1926 |
+
agent.hard_interrupt("cancel after provider return")
|
| 1927 |
+
allow_compress_return.set()
|
| 1928 |
+
worker.join(timeout=5)
|
| 1929 |
+
|
| 1930 |
+
assert not worker.is_alive()
|
| 1931 |
+
assert result["value"][0] is messages
|
| 1932 |
+
assert {
|
| 1933 |
+
name: copy.deepcopy(getattr(agent.context_compressor, name))
|
| 1934 |
+
for name in state_fields
|
| 1935 |
+
} == state_fields
|
| 1936 |
+
assert (
|
| 1937 |
+
agent.context_compressor._active_compression_telemetry
|
| 1938 |
+
is agent.context_compressor._last_compression_telemetry
|
| 1939 |
+
)
|
| 1940 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 1941 |
+
|
| 1942 |
+
agent.clear_interrupt()
|
| 1943 |
+
agent.context_compressor.compress.side_effect = lambda *_a, **_kw: [
|
| 1944 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] retry summary"},
|
| 1945 |
+
{"role": "user", "content": "tail"},
|
| 1946 |
+
]
|
| 1947 |
+
retried, _prompt = agent._compress_context(
|
| 1948 |
+
messages, "sys", approx_tokens=120_000
|
| 1949 |
+
)
|
| 1950 |
+
assert retried is not messages
|
| 1951 |
+
assert retried[0]["content"] == "[CONTEXT COMPACTION] retry summary"
|
| 1952 |
+
|
| 1953 |
+
|
| 1954 |
+
def test_force_cancel_restores_newer_durable_cooldown_captured_under_lease(
|
| 1955 |
+
tmp_path: Path,
|
| 1956 |
+
) -> None:
|
| 1957 |
+
"""A stale forced attempt rolls back to the lease-protected durable row."""
|
| 1958 |
+
from agent.auxiliary_client import AuxiliaryExplicitCancellation
|
| 1959 |
+
from agent.context_compressor import ContextCompressor
|
| 1960 |
+
|
| 1961 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 1962 |
+
session_id = "FORCE_CANCEL_DURABLE_COOLDOWN"
|
| 1963 |
+
db.create_session(session_id, source="cli")
|
| 1964 |
+
|
| 1965 |
+
# B binds first and therefore has no local cooldown. A then persists a
|
| 1966 |
+
# newer cooldown for the same durable session before B acquires its lease.
|
| 1967 |
+
stale_agent = _build_agent_with_db(
|
| 1968 |
+
db, session_id, stub_compressor=False
|
| 1969 |
+
)
|
| 1970 |
+
writer_agent = _build_agent_with_db(
|
| 1971 |
+
db, session_id, stub_compressor=False
|
| 1972 |
+
)
|
| 1973 |
+
stale = stale_agent.context_compressor
|
| 1974 |
+
writer = writer_agent.context_compressor
|
| 1975 |
+
assert isinstance(stale, ContextCompressor)
|
| 1976 |
+
assert isinstance(writer, ContextCompressor)
|
| 1977 |
+
assert stale._summary_failure_cooldown_until == 0.0
|
| 1978 |
+
|
| 1979 |
+
writer._record_compression_failure_cooldown(120.0, "newer durable failure")
|
| 1980 |
+
durable_before = tuple(
|
| 1981 |
+
db._conn.execute(
|
| 1982 |
+
"SELECT compression_failure_cooldown_until, compression_failure_error "
|
| 1983 |
+
"FROM sessions WHERE id = ?",
|
| 1984 |
+
(session_id,),
|
| 1985 |
+
).fetchone()
|
| 1986 |
+
)
|
| 1987 |
+
assert durable_before[0] is not None
|
| 1988 |
+
|
| 1989 |
+
stale_seed = {"seed": ["truly-pre-attempt"]}
|
| 1990 |
+
stale._compression_telemetry_seed = copy.deepcopy(stale_seed)
|
| 1991 |
+
stale._previous_summary = "pre-attempt-summary"
|
| 1992 |
+
stale_agent._compression_feasibility_checked = True
|
| 1993 |
+
stale_agent.compression_in_place = True
|
| 1994 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 1995 |
+
|
| 1996 |
+
real_clear = ContextCompressor._clear_compression_failure_cooldown
|
| 1997 |
+
|
| 1998 |
+
def _clear_then_hard_cancel() -> None:
|
| 1999 |
+
real_clear(stale)
|
| 2000 |
+
stale_agent._hard_interrupt_requested.set()
|
| 2001 |
+
raise AuxiliaryExplicitCancellation()
|
| 2002 |
+
|
| 2003 |
+
# Exercise the built-in force=True mutation point deterministically: force
|
| 2004 |
+
# clears the durable cooldown, then the frozen host cancellation unwinds it.
|
| 2005 |
+
stale._clear_compression_failure_cooldown = _clear_then_hard_cancel
|
| 2006 |
+
|
| 2007 |
+
compressed, _prompt = stale_agent._compress_context(
|
| 2008 |
+
messages,
|
| 2009 |
+
"sys",
|
| 2010 |
+
approx_tokens=120_000,
|
| 2011 |
+
force=True,
|
| 2012 |
+
)
|
| 2013 |
+
|
| 2014 |
+
assert compressed is messages
|
| 2015 |
+
durable_after = tuple(
|
| 2016 |
+
db._conn.execute(
|
| 2017 |
+
"SELECT compression_failure_cooldown_until, compression_failure_error "
|
| 2018 |
+
"FROM sessions WHERE id = ?",
|
| 2019 |
+
(session_id,),
|
| 2020 |
+
).fetchone()
|
| 2021 |
+
)
|
| 2022 |
+
assert durable_after == durable_before
|
| 2023 |
+
assert stale._summary_failure_cooldown_until > time.monotonic()
|
| 2024 |
+
assert stale._last_summary_error == "newer durable failure"
|
| 2025 |
+
assert stale._cooldown_persist_failed is False
|
| 2026 |
+
assert stale._compression_telemetry_seed == stale_seed
|
| 2027 |
+
assert stale._previous_summary == "pre-attempt-summary"
|
| 2028 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 2029 |
+
|
| 2030 |
+
# A future compressor refresh must still observe the exact row rather than
|
| 2031 |
+
# the cancelled force attempt having permanently cleared it.
|
| 2032 |
+
future_agent = _build_agent_with_db(
|
| 2033 |
+
db, session_id, stub_compressor=False
|
| 2034 |
+
)
|
| 2035 |
+
future = future_agent.context_compressor.get_active_compression_failure_cooldown(
|
| 2036 |
+
refresh=True
|
| 2037 |
+
)
|
| 2038 |
+
assert future is not None
|
| 2039 |
+
assert future["error"] == "newer durable failure"
|
| 2040 |
+
|
| 2041 |
+
|
| 2042 |
+
def test_unrelated_interrupted_error_propagates_and_releases_compression_lease(
|
| 2043 |
+
tmp_path: Path,
|
| 2044 |
+
) -> None:
|
| 2045 |
+
"""A plugin/OS InterruptedError is a failure, not an explicit transaction abort."""
|
| 2046 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 2047 |
+
session_id = "UNRELATED_INTERRUPT_COMPRESSION_TEST"
|
| 2048 |
+
db.create_session(session_id, source="cli")
|
| 2049 |
+
|
| 2050 |
+
agent = _build_agent_with_db(db, session_id)
|
| 2051 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 2052 |
+
|
| 2053 |
+
def _provider_interrupted(*_args, **_kwargs):
|
| 2054 |
+
messages[0]["content"] = "must be rolled back"
|
| 2055 |
+
raise InterruptedError("provider syscall interrupted")
|
| 2056 |
+
|
| 2057 |
+
agent.context_compressor.compress.side_effect = _provider_interrupted
|
| 2058 |
+
|
| 2059 |
+
with pytest.raises(InterruptedError, match="provider syscall interrupted"):
|
| 2060 |
+
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 2061 |
+
|
| 2062 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 2063 |
+
db.append_message(session_id, "assistant", "writes recovered")
|
| 2064 |
+
|
| 2065 |
+
|
| 2066 |
+
def test_redirect_interrupt_remains_protected_during_compression(tmp_path: Path) -> None:
|
| 2067 |
+
"""Redirects use interrupt_requested=True/message=None; only the atomic
|
| 2068 |
+
hard-cancel event may override summary protection."""
|
| 2069 |
+
from agent import auxiliary_client as aux
|
| 2070 |
+
|
| 2071 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 2072 |
+
session_id = "REDIRECT_COMPRESSION_TEST"
|
| 2073 |
+
db.create_session(session_id, source="cli")
|
| 2074 |
+
agent = _build_agent_with_db(db, session_id)
|
| 2075 |
+
agent._interrupt_requested = True
|
| 2076 |
+
agent._interrupt_message = None
|
| 2077 |
+
agent._pending_redirect = "new correction"
|
| 2078 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 2079 |
+
|
| 2080 |
+
def _protected_noop(current, **_kwargs):
|
| 2081 |
+
assert aux._aux_interrupt_cancel_requested() is False
|
| 2082 |
+
return copy.deepcopy(current)
|
| 2083 |
+
|
| 2084 |
+
agent.context_compressor.compress.side_effect = _protected_noop
|
| 2085 |
+
|
| 2086 |
+
compressed, _prompt = agent._compress_context(
|
| 2087 |
+
messages, "sys", approx_tokens=120_000
|
| 2088 |
+
)
|
| 2089 |
+
|
| 2090 |
+
assert compressed == messages
|
| 2091 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 2092 |
+
|
| 2093 |
+
|
| 2094 |
+
def test_hard_cancel_between_compress_return_and_commit_begin_wins_atomically(
|
| 2095 |
+
tmp_path: Path,
|
| 2096 |
+
) -> None:
|
| 2097 |
+
"""The hard-stop admission and commit admission share one fence lock."""
|
| 2098 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 2099 |
+
session_id = "HARD_CANCEL_COMMIT_RACE"
|
| 2100 |
+
db.create_session(session_id, source="tui")
|
| 2101 |
+
agent = _build_agent_with_db(db, session_id)
|
| 2102 |
+
agent.compression_in_place = True
|
| 2103 |
+
agent._cached_system_prompt = "sys"
|
| 2104 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 2105 |
+
before_commit = threading.Event()
|
| 2106 |
+
allow_commit_check = threading.Event()
|
| 2107 |
+
|
| 2108 |
+
class _CommitBarrierList(list):
|
| 2109 |
+
def __eq__(self, other):
|
| 2110 |
+
before_commit.set()
|
| 2111 |
+
assert allow_commit_check.wait(timeout=5)
|
| 2112 |
+
return super().__eq__(other)
|
| 2113 |
+
|
| 2114 |
+
agent.context_compressor.compress.side_effect = lambda *_a, **_kw: _CommitBarrierList(
|
| 2115 |
+
[
|
| 2116 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
| 2117 |
+
{"role": "user", "content": "tail"},
|
| 2118 |
+
]
|
| 2119 |
+
)
|
| 2120 |
+
archive_spy = MagicMock(wraps=db.archive_and_compact)
|
| 2121 |
+
db.archive_and_compact = archive_spy
|
| 2122 |
+
result: dict[str, tuple] = {}
|
| 2123 |
+
worker = threading.Thread(
|
| 2124 |
+
target=lambda: result.setdefault(
|
| 2125 |
+
"value", agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 2126 |
+
),
|
| 2127 |
+
daemon=True,
|
| 2128 |
+
)
|
| 2129 |
+
worker.start()
|
| 2130 |
+
assert before_commit.wait(timeout=2)
|
| 2131 |
+
|
| 2132 |
+
agent.hard_interrupt("cancel before commit admission")
|
| 2133 |
+
allow_commit_check.set()
|
| 2134 |
+
worker.join(timeout=5)
|
| 2135 |
+
|
| 2136 |
+
assert not worker.is_alive()
|
| 2137 |
+
assert result["value"][0] is messages
|
| 2138 |
+
archive_spy.assert_not_called()
|
| 2139 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 2140 |
+
|
| 2141 |
+
|
| 2142 |
+
def test_hard_stop_waits_for_commit_already_admitted(tmp_path: Path) -> None:
|
| 2143 |
+
"""A surfaced stop never races an untracked post-return transcript commit."""
|
| 2144 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 2145 |
+
session_id = "HARD_CANCEL_AFTER_COMMIT_ADMISSION"
|
| 2146 |
+
db.create_session(session_id, source="tui")
|
| 2147 |
+
agent = _build_agent_with_db(db, session_id)
|
| 2148 |
+
agent.compression_in_place = True
|
| 2149 |
+
agent._cached_system_prompt = "sys"
|
| 2150 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 2151 |
+
commit_started = threading.Event()
|
| 2152 |
+
allow_commit = threading.Event()
|
| 2153 |
+
stop_returned = threading.Event()
|
| 2154 |
+
original_archive = db.archive_and_compact
|
| 2155 |
+
|
| 2156 |
+
def _blocked_archive(*args, **kwargs):
|
| 2157 |
+
commit_started.set()
|
| 2158 |
+
assert allow_commit.wait(timeout=5)
|
| 2159 |
+
return original_archive(*args, **kwargs)
|
| 2160 |
+
|
| 2161 |
+
db.archive_and_compact = _blocked_archive
|
| 2162 |
+
agent.context_compressor.compress.side_effect = lambda *_a, **_kw: [
|
| 2163 |
+
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
| 2164 |
+
{"role": "user", "content": "tail"},
|
| 2165 |
+
]
|
| 2166 |
+
compression_result: dict[str, tuple] = {}
|
| 2167 |
+
compression = threading.Thread(
|
| 2168 |
+
target=lambda: compression_result.setdefault(
|
| 2169 |
+
"value", agent._compress_context(messages, "sys", approx_tokens=120_000)
|
| 2170 |
+
),
|
| 2171 |
+
daemon=True,
|
| 2172 |
+
)
|
| 2173 |
+
compression.start()
|
| 2174 |
+
assert commit_started.wait(timeout=2)
|
| 2175 |
+
|
| 2176 |
+
stop = threading.Thread(
|
| 2177 |
+
target=lambda: (
|
| 2178 |
+
agent.hard_interrupt("stop after commit admission"),
|
| 2179 |
+
stop_returned.set(),
|
| 2180 |
+
),
|
| 2181 |
+
daemon=True,
|
| 2182 |
+
)
|
| 2183 |
+
stop.start()
|
| 2184 |
+
assert not stop_returned.wait(timeout=0.1)
|
| 2185 |
+
allow_commit.set()
|
| 2186 |
+
compression.join(timeout=5)
|
| 2187 |
+
stop.join(timeout=5)
|
| 2188 |
+
|
| 2189 |
+
assert not compression.is_alive()
|
| 2190 |
+
assert not stop.is_alive()
|
| 2191 |
+
assert stop_returned.is_set()
|
| 2192 |
+
assert compression_result["value"][0][0]["content"] == (
|
| 2193 |
+
"[CONTEXT COMPACTION] summary"
|
| 2194 |
+
)
|
| 2195 |
+
assert agent._hard_interrupt_requested.is_set()
|
| 2196 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 2197 |
+
|
| 2198 |
+
|
| 2199 |
+
@pytest.mark.parametrize("deadline_offset", [-10.0, 0.05, None])
|
| 2200 |
+
def test_force_cancel_restores_exact_expired_or_expiring_cooldown_row(
|
| 2201 |
+
tmp_path: Path,
|
| 2202 |
+
deadline_offset: float | None,
|
| 2203 |
+
) -> None:
|
| 2204 |
+
"""Cancellation preserves raw cooldown columns even after their deadline."""
|
| 2205 |
+
from agent.auxiliary_client import AuxiliaryExplicitCancellation
|
| 2206 |
+
from agent.context_compressor import ContextCompressor
|
| 2207 |
+
|
| 2208 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 2209 |
+
session_id = f"RAW_COOLDOWN_{deadline_offset}"
|
| 2210 |
+
db.create_session(session_id, source="cli")
|
| 2211 |
+
deadline = time.time() + deadline_offset if deadline_offset is not None else None
|
| 2212 |
+
db.restore_compression_failure_cooldown_row(
|
| 2213 |
+
session_id,
|
| 2214 |
+
{
|
| 2215 |
+
"session_exists": True,
|
| 2216 |
+
"cooldown_until": deadline,
|
| 2217 |
+
"error": "expired-but-exact",
|
| 2218 |
+
},
|
| 2219 |
+
)
|
| 2220 |
+
before = db.get_compression_failure_cooldown_row(session_id)
|
| 2221 |
+
|
| 2222 |
+
agent = _build_agent_with_db(db, session_id, stub_compressor=False)
|
| 2223 |
+
compressor = agent.context_compressor
|
| 2224 |
+
assert isinstance(compressor, ContextCompressor)
|
| 2225 |
+
# A stale local persistence-failure marker must not suppress restoration
|
| 2226 |
+
# once the raw durable row was captured authoritatively under the lease.
|
| 2227 |
+
compressor._cooldown_persist_failed = True
|
| 2228 |
+
agent._compression_feasibility_checked = True
|
| 2229 |
+
agent.compression_in_place = True
|
| 2230 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 2231 |
+
real_clear = ContextCompressor._clear_compression_failure_cooldown
|
| 2232 |
+
|
| 2233 |
+
def _mutate_then_cancel() -> None:
|
| 2234 |
+
real_clear(compressor)
|
| 2235 |
+
if deadline_offset is not None and deadline_offset > 0:
|
| 2236 |
+
assert deadline is not None
|
| 2237 |
+
while time.time() <= deadline:
|
| 2238 |
+
time.sleep(0.005)
|
| 2239 |
+
agent._hard_interrupt_requested.set()
|
| 2240 |
+
raise AuxiliaryExplicitCancellation()
|
| 2241 |
+
|
| 2242 |
+
compressor._clear_compression_failure_cooldown = _mutate_then_cancel
|
| 2243 |
+
|
| 2244 |
+
compressed, _prompt = agent._compress_context(
|
| 2245 |
+
messages,
|
| 2246 |
+
"sys",
|
| 2247 |
+
approx_tokens=120_000,
|
| 2248 |
+
force=True,
|
| 2249 |
+
)
|
| 2250 |
+
|
| 2251 |
+
assert compressed is messages
|
| 2252 |
+
assert db.get_compression_failure_cooldown_row(session_id) == before
|
| 2253 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 2254 |
+
|
| 2255 |
+
|
| 2256 |
+
def test_exact_cooldown_restore_tolerates_a_concurrently_deleted_session(
|
| 2257 |
+
tmp_path: Path,
|
| 2258 |
+
) -> None:
|
| 2259 |
+
"""A deleted session has no cooldown state left to compensate (#106271)."""
|
| 2260 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 2261 |
+
session_id = "COOLDOWN_ROLLBACK_DELETED_SESSION"
|
| 2262 |
+
db.create_session(session_id, source="cli")
|
| 2263 |
+
snapshot = db.get_compression_failure_cooldown_row(session_id)
|
| 2264 |
+
assert snapshot["session_exists"] is True
|
| 2265 |
+
|
| 2266 |
+
assert db.delete_session(session_id) is True
|
| 2267 |
+
db.restore_compression_failure_cooldown_row(session_id, snapshot)
|
| 2268 |
+
|
| 2269 |
+
assert db.get_compression_failure_cooldown_row(session_id) == {
|
| 2270 |
+
"session_exists": False,
|
| 2271 |
+
"cooldown_until": None,
|
| 2272 |
+
"error": None,
|
| 2273 |
+
}
|
| 2274 |
+
|
| 2275 |
+
|
| 2276 |
+
def test_exact_cooldown_restore_tolerates_deletion_before_verification(
|
| 2277 |
+
tmp_path: Path,
|
| 2278 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 2279 |
+
) -> None:
|
| 2280 |
+
"""A deletion after the compensating write is still an intentional no-op (#106271)."""
|
| 2281 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 2282 |
+
session_id = "COOLDOWN_ROLLBACK_DELETE_DURING_VERIFY"
|
| 2283 |
+
db.create_session(session_id, source="cli")
|
| 2284 |
+
snapshot = db.get_compression_failure_cooldown_row(session_id)
|
| 2285 |
+
real_execute_write = db._execute_write
|
| 2286 |
+
|
| 2287 |
+
def _restore_then_delete(callback):
|
| 2288 |
+
monkeypatch.setattr(db, "_execute_write", real_execute_write)
|
| 2289 |
+
result = real_execute_write(callback)
|
| 2290 |
+
assert db.delete_session(session_id) is True
|
| 2291 |
+
return result
|
| 2292 |
+
|
| 2293 |
+
monkeypatch.setattr(db, "_execute_write", _restore_then_delete)
|
| 2294 |
+
db.restore_compression_failure_cooldown_row(session_id, snapshot)
|
| 2295 |
+
|
| 2296 |
+
assert db.get_compression_failure_cooldown_row(session_id)["session_exists"] is False
|
| 2297 |
+
|
| 2298 |
+
|
| 2299 |
+
def test_cooldown_rollback_failure_surfaces_and_releases_lease(
|
| 2300 |
+
tmp_path: Path,
|
| 2301 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 2302 |
+
) -> None:
|
| 2303 |
+
"""A failed compensating write cannot masquerade as a mutation-free cancel."""
|
| 2304 |
+
from agent.auxiliary_client import AuxiliaryExplicitCancellation
|
| 2305 |
+
from agent.context_compressor import ContextCompressor
|
| 2306 |
+
|
| 2307 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 2308 |
+
session_id = "COOLDOWN_ROLLBACK_WRITE_FAILURE"
|
| 2309 |
+
db.create_session(session_id, source="cli")
|
| 2310 |
+
db.record_compression_failure_cooldown(
|
| 2311 |
+
session_id,
|
| 2312 |
+
time.time() + 120.0,
|
| 2313 |
+
"must-restore",
|
| 2314 |
+
)
|
| 2315 |
+
agent = _build_agent_with_db(db, session_id, stub_compressor=False)
|
| 2316 |
+
compressor = agent.context_compressor
|
| 2317 |
+
assert isinstance(compressor, ContextCompressor)
|
| 2318 |
+
agent._compression_feasibility_checked = True
|
| 2319 |
+
agent.compression_in_place = True
|
| 2320 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 2321 |
+
real_clear = ContextCompressor._clear_compression_failure_cooldown
|
| 2322 |
+
|
| 2323 |
+
def _mutate_then_cancel() -> None:
|
| 2324 |
+
real_clear(compressor)
|
| 2325 |
+
agent._hard_interrupt_requested.set()
|
| 2326 |
+
raise AuxiliaryExplicitCancellation()
|
| 2327 |
+
|
| 2328 |
+
compressor._clear_compression_failure_cooldown = _mutate_then_cancel
|
| 2329 |
+
|
| 2330 |
+
def _rollback_write_fails(_self, _session_id, _snapshot) -> None:
|
| 2331 |
+
raise sqlite3.OperationalError("forced rollback write failure")
|
| 2332 |
+
|
| 2333 |
+
monkeypatch.setattr(
|
| 2334 |
+
SessionDB,
|
| 2335 |
+
"restore_compression_failure_cooldown_row",
|
| 2336 |
+
_rollback_write_fails,
|
| 2337 |
+
)
|
| 2338 |
+
|
| 2339 |
+
with pytest.raises(sqlite3.OperationalError, match="forced rollback write failure"):
|
| 2340 |
+
agent._compress_context(
|
| 2341 |
+
messages,
|
| 2342 |
+
"sys",
|
| 2343 |
+
approx_tokens=120_000,
|
| 2344 |
+
force=True,
|
| 2345 |
+
)
|
| 2346 |
+
|
| 2347 |
+
assert db.get_compression_lock_holder(session_id) is None
|
| 2348 |
+
|
| 2349 |
+
|
| 2350 |
+
def test_exact_cooldown_restore_api_propagates_sqlite_write_failure(
|
| 2351 |
+
tmp_path: Path,
|
| 2352 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 2353 |
+
) -> None:
|
| 2354 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 2355 |
+
session_id = "RAW_COOLDOWN_WRITE_FAILURE"
|
| 2356 |
+
db.create_session(session_id, source="test")
|
| 2357 |
+
|
| 2358 |
+
def _write_fails(_callback) -> None:
|
| 2359 |
+
raise sqlite3.OperationalError("forced low-level write failure")
|
| 2360 |
+
|
| 2361 |
+
monkeypatch.setattr(db, "_execute_write", _write_fails)
|
| 2362 |
+
|
| 2363 |
+
with pytest.raises(sqlite3.OperationalError, match="forced low-level write failure"):
|
| 2364 |
+
db.restore_compression_failure_cooldown_row(
|
| 2365 |
+
session_id,
|
| 2366 |
+
{
|
| 2367 |
+
"session_exists": True,
|
| 2368 |
+
"cooldown_until": time.time() + 10.0,
|
| 2369 |
+
"error": "must propagate",
|
| 2370 |
+
},
|
| 2371 |
+
)
|
| 2372 |
+
|
| 2373 |
+
|
| 2374 |
+
def test_failed_split_arms_failure_cooldown(tmp_path: Path) -> None:
|
| 2375 |
+
"""Regression #97948 symptom B: a failed split/archive must arm the
|
| 2376 |
+
compression failure cooldown so the next automatic turn cannot
|
| 2377 |
+
immediately re-run the identical doomed compression."""
|
| 2378 |
+
from agent.conversation_compression import _SPLIT_FAILURE_COOLDOWN_SECONDS
|
| 2379 |
+
|
| 2380 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 2381 |
+
session_id = "SPLIT_FAIL_COOLDOWN_TEST"
|
| 2382 |
+
db.create_session(session_id, source="test")
|
| 2383 |
+
agent = _build_agent_with_db(db, session_id)
|
| 2384 |
+
setattr(agent, "compression_in_place", True)
|
| 2385 |
+
db.archive_and_compact = MagicMock(side_effect=RuntimeError("archive boom"))
|
| 2386 |
+
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
| 2387 |
+
|
| 2388 |
+
agent._compress_context(
|
| 2389 |
+
messages,
|
| 2390 |
+
"sys",
|
| 2391 |
+
approx_tokens=120_000,
|
| 2392 |
+
force=True,
|
| 2393 |
+
)
|
| 2394 |
+
|
| 2395 |
+
cooldown_calls = (
|
| 2396 |
+
agent.context_compressor._record_compression_failure_cooldown.call_args_list
|
| 2397 |
+
)
|
| 2398 |
+
assert len(cooldown_calls) == 1, (
|
| 2399 |
+
"split failure must arm the failure cooldown (#97948 symptom B)"
|
| 2400 |
+
)
|
| 2401 |
+
seconds, error = cooldown_calls[0].args
|
| 2402 |
+
assert seconds == _SPLIT_FAILURE_COOLDOWN_SECONDS
|
| 2403 |
+
assert "session_split_failed" in str(error)
|
tests/agent/test_compression_interrupt_protection.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression for #23975: context compression must survive a mid-flight
|
| 2 |
+
gateway interrupt.
|
| 3 |
+
|
| 4 |
+
While the compression summary LLM call is in flight, an incoming gateway
|
| 5 |
+
message sets the thread interrupt flag. The Codex Responses aux stream polls
|
| 6 |
+
that flag and used to raise InterruptedError unconditionally — aborting the
|
| 7 |
+
summary, which then fell back to a degraded static "summary unavailable"
|
| 8 |
+
marker (losing the real handoff). Compression now runs its summary call
|
| 9 |
+
under aux_interrupt_protection(), so the interrupt poll is masked for the
|
| 10 |
+
compression task only (timeouts and other aux tasks stay interruptible).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from unittest.mock import patch
|
| 16 |
+
|
| 17 |
+
import pytest
|
| 18 |
+
|
| 19 |
+
import agent.auxiliary_client as aux
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TestAuxInterruptProtection:
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_context_manager_is_reentrant(self):
|
| 26 |
+
with aux.aux_interrupt_protection():
|
| 27 |
+
assert aux._aux_interrupt_protected() is True
|
| 28 |
+
with aux.aux_interrupt_protection():
|
| 29 |
+
assert aux._aux_interrupt_protected() is True
|
| 30 |
+
# inner exit must NOT clear protection while still inside outer
|
| 31 |
+
assert aux._aux_interrupt_protected() is True
|
| 32 |
+
assert aux._aux_interrupt_protected() is False
|
| 33 |
+
|
| 34 |
+
def test_restores_on_exception(self):
|
| 35 |
+
try:
|
| 36 |
+
with aux.aux_interrupt_protection():
|
| 37 |
+
raise ValueError("boom")
|
| 38 |
+
except ValueError:
|
| 39 |
+
pass
|
| 40 |
+
assert aux._aux_interrupt_protected() is False
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_nested_protection_preserves_explicit_cancel_check(self):
|
| 44 |
+
"""A hard-cancel hook installed by the compression host survives the
|
| 45 |
+
compressor's nested protection scope."""
|
| 46 |
+
with aux.aux_interrupt_protection(cancel_check=lambda: True):
|
| 47 |
+
with aux.aux_interrupt_protection():
|
| 48 |
+
assert aux._aux_interrupt_protected() is True
|
| 49 |
+
assert aux._aux_interrupt_cancel_requested() is True
|
| 50 |
+
assert aux._aux_interrupt_cancel_requested() is False
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class TestCompressionProtectsSummaryCall:
|
| 54 |
+
"""The compressor must wrap its summary call_llm in aux_interrupt_protection
|
| 55 |
+
so a mid-flight interrupt doesn't abort it (#23975)."""
|
| 56 |
+
|
| 57 |
+
def test_compressor_call_site_uses_protection(self):
|
| 58 |
+
# The summary call must run inside aux_interrupt_protection. We assert
|
| 59 |
+
# the protection flag is ACTIVE at the moment call_llm is invoked.
|
| 60 |
+
from agent.context_compressor import ContextCompressor
|
| 61 |
+
|
| 62 |
+
seen = {}
|
| 63 |
+
|
| 64 |
+
class _Resp:
|
| 65 |
+
class _Choice:
|
| 66 |
+
class _Msg:
|
| 67 |
+
content = "[CONTEXT SUMMARY]: ok"
|
| 68 |
+
message = _Msg()
|
| 69 |
+
choices = [_Choice()]
|
| 70 |
+
|
| 71 |
+
def fake_call_llm(**kwargs):
|
| 72 |
+
# Capture whether protection was active during the call.
|
| 73 |
+
seen["protected"] = aux._aux_interrupt_protected()
|
| 74 |
+
seen["task"] = kwargs.get("task")
|
| 75 |
+
return _Resp()
|
| 76 |
+
|
| 77 |
+
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
| 78 |
+
c = ContextCompressor(model="test", quiet_mode=True)
|
| 79 |
+
|
| 80 |
+
msgs = [
|
| 81 |
+
{"role": "user", "content": "do a thing"},
|
| 82 |
+
{"role": "assistant", "content": "working"},
|
| 83 |
+
{"role": "user", "content": "more"},
|
| 84 |
+
{"role": "assistant", "content": "done"},
|
| 85 |
+
]
|
| 86 |
+
with patch("agent.context_compressor.call_llm", side_effect=fake_call_llm):
|
| 87 |
+
summary = c._generate_summary(msgs)
|
| 88 |
+
|
| 89 |
+
assert summary is not None
|
| 90 |
+
assert seen.get("task") == "compression"
|
| 91 |
+
assert seen.get("protected") is True, (
|
| 92 |
+
"compression summary call must run under aux_interrupt_protection"
|
| 93 |
+
)
|
| 94 |
+
# Protection must be cleared after the call returns.
|
| 95 |
+
assert aux._aux_interrupt_protected() is False
|
| 96 |
+
|
| 97 |
+
def test_explicit_interrupt_is_not_degraded_into_summary_fallback(self):
|
| 98 |
+
"""Ctrl+C cancellation must escape summary fallback so the outer
|
| 99 |
+
compression transaction can abort without rotating the session."""
|
| 100 |
+
from agent.context_compressor import ContextCompressor
|
| 101 |
+
|
| 102 |
+
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
| 103 |
+
c = ContextCompressor(model="test", quiet_mode=True)
|
| 104 |
+
|
| 105 |
+
msgs = [
|
| 106 |
+
{"role": "user", "content": "do a thing"},
|
| 107 |
+
{"role": "assistant", "content": "working"},
|
| 108 |
+
{"role": "user", "content": "more"},
|
| 109 |
+
{"role": "assistant", "content": "done"},
|
| 110 |
+
]
|
| 111 |
+
with aux.aux_interrupt_protection(cancel_check=lambda: True), patch(
|
| 112 |
+
"agent.context_compressor.call_llm",
|
| 113 |
+
side_effect=aux.AuxiliaryExplicitCancellation(),
|
| 114 |
+
):
|
| 115 |
+
try:
|
| 116 |
+
c._generate_summary(msgs)
|
| 117 |
+
except aux.AuxiliaryExplicitCancellation as exc:
|
| 118 |
+
assert exc.cause == "explicit_host_cancel"
|
| 119 |
+
else:
|
| 120 |
+
raise AssertionError("compression swallowed an explicit interrupt")
|
| 121 |
+
|
| 122 |
+
def test_non_explicit_interrupted_error_remains_provider_failure(self):
|
| 123 |
+
"""An unrelated provider/OS InterruptedError must keep the established
|
| 124 |
+
summary-failure fallback semantics when no host cancel was requested."""
|
| 125 |
+
from agent.context_compressor import ContextCompressor
|
| 126 |
+
|
| 127 |
+
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
| 128 |
+
c = ContextCompressor(model="test", quiet_mode=True)
|
| 129 |
+
|
| 130 |
+
msgs = [
|
| 131 |
+
{"role": "user", "content": "do a thing"},
|
| 132 |
+
{"role": "assistant", "content": "working"},
|
| 133 |
+
{"role": "user", "content": "more"},
|
| 134 |
+
{"role": "assistant", "content": "done"},
|
| 135 |
+
]
|
| 136 |
+
with patch(
|
| 137 |
+
"agent.context_compressor.call_llm",
|
| 138 |
+
side_effect=InterruptedError("provider syscall interrupted"),
|
| 139 |
+
):
|
| 140 |
+
assert c._generate_summary(msgs) is None
|
| 141 |
+
|
| 142 |
+
def test_explicit_interrupt_restores_rehydration_state(self):
|
| 143 |
+
"""Cancellation after the handoff scan must be a compressor no-op."""
|
| 144 |
+
from agent.context_compressor import ContextCompressor
|
| 145 |
+
|
| 146 |
+
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
| 147 |
+
c = ContextCompressor(
|
| 148 |
+
model="test",
|
| 149 |
+
protect_first_n=1,
|
| 150 |
+
protect_last_n=1,
|
| 151 |
+
quiet_mode=True,
|
| 152 |
+
)
|
| 153 |
+
c._previous_summary = "foreign-session-summary"
|
| 154 |
+
c._summary_has_user_turn = False
|
| 155 |
+
msgs = [
|
| 156 |
+
{"role": "system", "content": "system"},
|
| 157 |
+
{"role": "user", "content": "one"},
|
| 158 |
+
{"role": "assistant", "content": "two"},
|
| 159 |
+
{"role": "user", "content": "three"},
|
| 160 |
+
{"role": "assistant", "content": "four"},
|
| 161 |
+
{"role": "user", "content": "five"},
|
| 162 |
+
{"role": "assistant", "content": "six"},
|
| 163 |
+
{"role": "user", "content": "tail"},
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
with patch.object(
|
| 167 |
+
c,
|
| 168 |
+
"_generate_summary",
|
| 169 |
+
side_effect=aux.AuxiliaryExplicitCancellation(),
|
| 170 |
+
):
|
| 171 |
+
with pytest.raises(aux.AuxiliaryExplicitCancellation):
|
| 172 |
+
c.compress(msgs)
|
| 173 |
+
|
| 174 |
+
assert c._previous_summary == "foreign-session-summary"
|
| 175 |
+
assert c._summary_has_user_turn is False
|
tests/agent/test_compression_max_attempts_config.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""compression.max_attempts — config-driven compression retry cap.
|
| 2 |
+
|
| 3 |
+
The conversation loop's compression retry cap was hardcoded to 3, stranding
|
| 4 |
+
sessions that legitimately need more rounds — e.g. a restart history reload
|
| 5 |
+
whose incompressible tool schemas keep the request estimate above the
|
| 6 |
+
threshold while the messages themselves compress fine (the #62605 failure
|
| 7 |
+
class). The cap is now parsed from ``compression.max_attempts`` in
|
| 8 |
+
``agent_init`` and read by the loop via
|
| 9 |
+
``getattr(agent, "max_compression_attempts", 3)``.
|
| 10 |
+
|
| 11 |
+
These tests pin the parse/validate/attach seam: default preserved, custom
|
| 12 |
+
value honored, floor and ceiling enforced, garbage tolerated.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import contextlib
|
| 18 |
+
import io
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
from hermes_state import SessionDB
|
| 22 |
+
from run_agent import AIAgent
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _config(max_attempts=None) -> dict:
|
| 26 |
+
compression = {
|
| 27 |
+
"enabled": True,
|
| 28 |
+
"threshold": 0.50,
|
| 29 |
+
"target_ratio": 0.20,
|
| 30 |
+
"protect_first_n": 3,
|
| 31 |
+
"protect_last_n": 20,
|
| 32 |
+
}
|
| 33 |
+
if max_attempts is not None:
|
| 34 |
+
compression["max_attempts"] = max_attempts
|
| 35 |
+
return {
|
| 36 |
+
"compression": compression,
|
| 37 |
+
"prompt_caching": {"cache_ttl": "5m"},
|
| 38 |
+
"sessions": {},
|
| 39 |
+
"bedrock": {},
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _make_agent(monkeypatch, tmp_path: Path, *, max_attempts=None):
|
| 44 |
+
from hermes_cli import config as config_mod
|
| 45 |
+
|
| 46 |
+
monkeypatch.setattr(
|
| 47 |
+
config_mod, "load_config", lambda: _config(max_attempts=max_attempts)
|
| 48 |
+
)
|
| 49 |
+
monkeypatch.setattr(
|
| 50 |
+
config_mod, "load_config_readonly", lambda: _config(max_attempts=max_attempts)
|
| 51 |
+
)
|
| 52 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 53 |
+
with contextlib.redirect_stdout(io.StringIO()):
|
| 54 |
+
agent = AIAgent(
|
| 55 |
+
base_url="https://chatgpt.com/backend-api/codex",
|
| 56 |
+
api_key="test-key",
|
| 57 |
+
provider="openai-codex",
|
| 58 |
+
model="gpt-5.5",
|
| 59 |
+
enabled_toolsets=[],
|
| 60 |
+
disabled_toolsets=[],
|
| 61 |
+
quiet_mode=True,
|
| 62 |
+
skip_memory=True,
|
| 63 |
+
session_db=db,
|
| 64 |
+
session_id="max-attempts-test",
|
| 65 |
+
)
|
| 66 |
+
return agent
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class TestCompressionMaxAttemptsConfig:
|
| 70 |
+
def test_default_is_three_when_unset(self, monkeypatch, tmp_path):
|
| 71 |
+
agent = _make_agent(monkeypatch, tmp_path)
|
| 72 |
+
assert agent.max_compression_attempts == 3
|
| 73 |
+
|
| 74 |
+
def test_custom_value_is_honored(self, monkeypatch, tmp_path):
|
| 75 |
+
agent = _make_agent(monkeypatch, tmp_path, max_attempts=6)
|
| 76 |
+
assert agent.max_compression_attempts == 6
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_loop_pickup_degrades_to_default_when_attribute_missing(
|
| 85 |
+
self, monkeypatch, tmp_path
|
| 86 |
+
):
|
| 87 |
+
# The loop reads getattr(agent, "max_compression_attempts", 3): a
|
| 88 |
+
# configured agent exposes its value, and an object without the
|
| 89 |
+
# attribute (older pickle / minimal stub) degrades to the prior
|
| 90 |
+
# hardcoded behavior.
|
| 91 |
+
agent = _make_agent(monkeypatch, tmp_path, max_attempts=7)
|
| 92 |
+
assert getattr(agent, "max_compression_attempts", 3) == 7
|
| 93 |
+
assert getattr(object(), "max_compression_attempts", 3) == 3
|
tests/agent/test_compression_split_failure_cooldown.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests: a failed compression split must arm the failure cooldown.
|
| 2 |
+
|
| 3 |
+
Extracted from PR #98137's test file (author: vsd2807); two sibling unit
|
| 4 |
+
tests were dropped as duplicates of existing coverage in
|
| 5 |
+
test_compression_rotation_state.py / test_context_compressor.py, and the
|
| 6 |
+
timeout-reconciliation tests were not carried because that production path
|
| 7 |
+
was not salvaged (blocking review on #98137).
|
| 8 |
+
|
| 9 |
+
Issue #97948 symptom B: without the cooldown, the turn after a
|
| 10 |
+
session_split_failed abort immediately re-runs the identical doomed
|
| 11 |
+
compression.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import time
|
| 15 |
+
from unittest.mock import MagicMock
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_manual_compress_bypasses_cooldown():
|
| 19 |
+
"""Manual /compress (force=True) bypasses cooldown — existing behavior preserved."""
|
| 20 |
+
from agent.conversation_compression import compress_context
|
| 21 |
+
|
| 22 |
+
compressor_mock = MagicMock()
|
| 23 |
+
compressor_mock._summary_failure_cooldown_until = time.monotonic() + 60.0
|
| 24 |
+
compressor_mock._last_summary_error = "session_split_failed"
|
| 25 |
+
compressor_mock._cooldown_persist_failed = False
|
| 26 |
+
compressor_mock._anti_thrash_recovery_deadline = 0.0
|
| 27 |
+
compressor_mock._consecutive_ineffective_compressions = 0
|
| 28 |
+
compressor_mock._summary_failure_streak = 0
|
| 29 |
+
compressor_mock._last_compaction_boundary_tokens = None
|
| 30 |
+
compressor_mock._verify_compaction_cleared_threshold = False
|
| 31 |
+
compressor_mock._proactive_prune_rearm_tokens = None
|
| 32 |
+
compressor_mock.compression_count = 0
|
| 33 |
+
compressor_mock.threshold_tokens = 10000
|
| 34 |
+
compressor_mock.context_length = 128000
|
| 35 |
+
compressor_mock.tail_token_budget = 25000
|
| 36 |
+
compressor_mock.summary_target_ratio = 0.1
|
| 37 |
+
compressor_mock._last_cooldown_refresh_was_authoritative = None
|
| 38 |
+
|
| 39 |
+
agent_mock = MagicMock()
|
| 40 |
+
agent_mock.context_compressor = compressor_mock
|
| 41 |
+
agent_mock.compression_enabled = True
|
| 42 |
+
agent_mock.session_id = "test-session"
|
| 43 |
+
agent_mock._session_db = MagicMock()
|
| 44 |
+
agent_mock._session_db.try_acquire_compression_lock.return_value = False
|
| 45 |
+
|
| 46 |
+
messages = [{"role": "user", "content": "hello"}]
|
| 47 |
+
|
| 48 |
+
result = compress_context(
|
| 49 |
+
agent_mock,
|
| 50 |
+
messages,
|
| 51 |
+
None,
|
| 52 |
+
force=True,
|
| 53 |
+
task_id="test",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
assert result is not None
|
tests/agent/test_content_policy_blocked.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression guard for #18028: provider content-policy / safety-filter
|
| 2 |
+
blocks must classify as ``content_policy_blocked``, be non-retryable, and
|
| 3 |
+
trigger the ``is_client_error`` abort path so the loop jumps straight to a
|
| 4 |
+
configured fallback or surfaces a clear policy-block message — instead of
|
| 5 |
+
burning ``api_max_retries`` paid attempts on a deterministic refusal and
|
| 6 |
+
delivering "API failed after 3 retries" to Telegram/cron with no provider
|
| 7 |
+
context.
|
| 8 |
+
|
| 9 |
+
Real-world symptom from the issue:
|
| 10 |
+
``API call failed after 3 retries — This content was flagged for
|
| 11 |
+
possible cybersecurity risk... | provider=openai-codex model=gpt-5.5``
|
| 12 |
+
repeating across cron jobs and gateway sessions, with the user unable to
|
| 13 |
+
tell whether the gateway was broken, the model was down, or their prompt
|
| 14 |
+
was the problem.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TestContentPolicyBlockedClassification:
|
| 20 |
+
"""Verify classify_api_error returns the right shape so downstream
|
| 21 |
+
recovery (fallback activation, final_response wording) fires correctly.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
def test_openai_codex_cybersecurity_no_status(self):
|
| 25 |
+
"""The reported #18028 case — SDK raises without a status code."""
|
| 26 |
+
from agent.error_classifier import classify_api_error, FailoverReason
|
| 27 |
+
|
| 28 |
+
e = Exception(
|
| 29 |
+
"This content was flagged for possible cybersecurity risk. "
|
| 30 |
+
"If this seems wrong, try rephrasing your request. To get "
|
| 31 |
+
"authorized for security work, join the Trusted Access for "
|
| 32 |
+
"Cyber program."
|
| 33 |
+
)
|
| 34 |
+
result = classify_api_error(e, provider="openai-codex", model="gpt-5.5")
|
| 35 |
+
# Must NOT fall into the retryable ``unknown`` bucket — that's what
|
| 36 |
+
# caused the 3x retry burn.
|
| 37 |
+
assert result.reason == FailoverReason.content_policy_blocked
|
| 38 |
+
assert result.retryable is False
|
| 39 |
+
# Recovery is fallback model, not credential rotation or compression.
|
| 40 |
+
assert result.should_fallback is True
|
| 41 |
+
assert result.should_compress is False
|
| 42 |
+
assert result.should_rotate_credential is False
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class TestContentPolicyTriggersClientErrorAbort:
|
| 47 |
+
"""Mirror the ``is_client_error`` predicate in
|
| 48 |
+
``agent/conversation_loop.py`` and verify
|
| 49 |
+
``FailoverReason.content_policy_blocked`` resolves to True so the loop
|
| 50 |
+
aborts (after attempting fallback) instead of falling into the
|
| 51 |
+
retry-backoff path.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def _mirror_is_client_error(
|
| 55 |
+
self,
|
| 56 |
+
*,
|
| 57 |
+
classified_retryable: bool,
|
| 58 |
+
classified_reason,
|
| 59 |
+
classified_should_compress: bool = False,
|
| 60 |
+
is_local_validation_error: bool = False,
|
| 61 |
+
is_context_length_error: bool = False,
|
| 62 |
+
) -> bool:
|
| 63 |
+
"""Exact shape of conversation_loop.py's is_client_error check.
|
| 64 |
+
|
| 65 |
+
Kept in lock-step with the source. If you change one, change both.
|
| 66 |
+
"""
|
| 67 |
+
from agent.error_classifier import FailoverReason
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
is_local_validation_error
|
| 71 |
+
or (
|
| 72 |
+
not classified_retryable
|
| 73 |
+
and not classified_should_compress
|
| 74 |
+
and classified_reason not in {
|
| 75 |
+
FailoverReason.rate_limit,
|
| 76 |
+
FailoverReason.overloaded,
|
| 77 |
+
FailoverReason.context_overflow,
|
| 78 |
+
FailoverReason.payload_too_large,
|
| 79 |
+
FailoverReason.long_context_tier,
|
| 80 |
+
FailoverReason.thinking_signature,
|
| 81 |
+
}
|
| 82 |
+
)
|
| 83 |
+
) and not is_context_length_error
|
| 84 |
+
|
| 85 |
+
def test_content_policy_blocked_triggers_abort(self):
|
| 86 |
+
"""Safety-filter block must reach is_client_error → fallback/abort."""
|
| 87 |
+
from agent.error_classifier import FailoverReason
|
| 88 |
+
|
| 89 |
+
# What classify_api_error returns for a content-policy block:
|
| 90 |
+
# reason=content_policy_blocked, retryable=False, should_compress=False
|
| 91 |
+
assert self._mirror_is_client_error(
|
| 92 |
+
classified_retryable=False,
|
| 93 |
+
classified_reason=FailoverReason.content_policy_blocked,
|
| 94 |
+
), (
|
| 95 |
+
"FailoverReason.content_policy_blocked must trigger the "
|
| 96 |
+
"is_client_error path so fallback fires immediately instead of "
|
| 97 |
+
"burning api_max_retries paid attempts on a deterministic "
|
| 98 |
+
"safety refusal — see #18028."
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class TestContentPolicyPatternsAreNarrow:
|
| 103 |
+
"""Defensive guard: the safety-filter patterns must not collide with
|
| 104 |
+
benign error wording from billing / format / generic 400 errors. If
|
| 105 |
+
these regress to ``content_policy_blocked``, recovery will route to
|
| 106 |
+
the wrong code path (fallback model instead of credential rotation).
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
def test_generic_400_format_error_not_misclassified(self):
|
| 110 |
+
from agent.error_classifier import classify_api_error, FailoverReason
|
| 111 |
+
|
| 112 |
+
class _Err(Exception):
|
| 113 |
+
def __init__(self, msg, status_code):
|
| 114 |
+
super().__init__(msg)
|
| 115 |
+
self.status_code = status_code
|
| 116 |
+
|
| 117 |
+
e = _Err("Invalid request: messages must be a non-empty list", status_code=400)
|
| 118 |
+
result = classify_api_error(e, provider="openai", model="gpt-4o")
|
| 119 |
+
assert result.reason != FailoverReason.content_policy_blocked
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_openrouter_account_policy_block_stays_distinct(self):
|
| 123 |
+
"""``provider_policy_blocked`` (OpenRouter account-level data
|
| 124 |
+
policy) must remain a separate classification from
|
| 125 |
+
``content_policy_blocked`` (upstream model safety filter) — they
|
| 126 |
+
have different recovery strategies.
|
| 127 |
+
"""
|
| 128 |
+
from agent.error_classifier import classify_api_error, FailoverReason
|
| 129 |
+
|
| 130 |
+
class _Err(Exception):
|
| 131 |
+
def __init__(self, msg, status_code):
|
| 132 |
+
super().__init__(msg)
|
| 133 |
+
self.status_code = status_code
|
| 134 |
+
|
| 135 |
+
e = _Err(
|
| 136 |
+
"No endpoints available matching your guardrail restrictions "
|
| 137 |
+
"and data policy",
|
| 138 |
+
status_code=404,
|
| 139 |
+
)
|
| 140 |
+
result = classify_api_error(e, provider="openrouter", model="anthropic/claude-opus")
|
| 141 |
+
assert result.reason == FailoverReason.provider_policy_blocked
|
| 142 |
+
assert result.reason != FailoverReason.content_policy_blocked
|
tests/agent/test_context_compressor_structural_backoff.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structural no-op backoff (#93022).
|
| 2 |
+
|
| 3 |
+
A compression attempt that finds nothing eligible inside the protection
|
| 4 |
+
window (too few messages / empty window / post-handoff residue) is "nothing
|
| 5 |
+
to compress right now", not an ineffective attempt: it must defer retries
|
| 6 |
+
transiently instead of arming the permanent anti-thrash breaker, so a short
|
| 7 |
+
session can still auto-compact after it grows real compressible material.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
from unittest.mock import MagicMock, patch
|
| 12 |
+
|
| 13 |
+
from agent.context_compressor import ContextCompressor
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _compressor(protect_first_n: int = 1) -> ContextCompressor:
|
| 17 |
+
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
| 18 |
+
return ContextCompressor(
|
| 19 |
+
model="test/model",
|
| 20 |
+
threshold_percent=0.85,
|
| 21 |
+
protect_first_n=protect_first_n,
|
| 22 |
+
protect_last_n=1,
|
| 23 |
+
quiet_mode=True,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _response(content: str):
|
| 28 |
+
mock_response = MagicMock()
|
| 29 |
+
mock_response.choices = [MagicMock()]
|
| 30 |
+
mock_response.choices[0].message.content = content
|
| 31 |
+
return mock_response
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_insufficient_messages_backs_off_without_strike():
|
| 35 |
+
"""Too few messages -> structural backoff, breaker stays untouched."""
|
| 36 |
+
compressor = _compressor()
|
| 37 |
+
messages = [
|
| 38 |
+
{"role": "system", "content": "system prompt"},
|
| 39 |
+
{"role": "user", "content": "hello"},
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
result = compressor.compress(messages, current_tokens=90_000)
|
| 43 |
+
|
| 44 |
+
assert result == messages
|
| 45 |
+
assert compressor._ineffective_compression_count == 0
|
| 46 |
+
assert compressor._structural_no_op_backoff_until > 0.0
|
| 47 |
+
telemetry = compressor._last_compression_telemetry or {}
|
| 48 |
+
assert telemetry.get("failure_class") == "insufficient_messages"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_no_compressible_window_backs_off_without_strike():
|
| 52 |
+
"""Transcript inside the tail budget -> backoff, breaker untouched."""
|
| 53 |
+
compressor = _compressor()
|
| 54 |
+
messages = [
|
| 55 |
+
{"role": "system", "content": "system prompt"},
|
| 56 |
+
{"role": "user", "content": "turn one"},
|
| 57 |
+
{"role": "assistant", "content": "answer one"},
|
| 58 |
+
{"role": "user", "content": "turn two"},
|
| 59 |
+
{"role": "assistant", "content": "answer two"},
|
| 60 |
+
{"role": "user", "content": "turn three"},
|
| 61 |
+
{"role": "assistant", "content": "answer three"},
|
| 62 |
+
{"role": "user", "content": "latest request in protected tail"},
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
with patch.object(compressor, "_find_tail_cut_by_tokens", return_value=2):
|
| 66 |
+
result = compressor.compress(messages, current_tokens=90_000)
|
| 67 |
+
|
| 68 |
+
assert result == messages
|
| 69 |
+
assert compressor._ineffective_compression_count == 0
|
| 70 |
+
assert compressor._structural_no_op_backoff_until > 0.0
|
| 71 |
+
telemetry = compressor._last_compression_telemetry or {}
|
| 72 |
+
assert telemetry.get("failure_class") == "no_compressible_window"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_gate_blocked_during_backoff_then_resumes():
|
| 76 |
+
"""should_compress defers during the backoff and recovers after it lapses.
|
| 77 |
+
|
| 78 |
+
The transcript sits over the compression threshold the whole time; only
|
| 79 |
+
the clock changes, proving the block is transient rather than a latched
|
| 80 |
+
breaker state.
|
| 81 |
+
"""
|
| 82 |
+
compressor = _compressor()
|
| 83 |
+
|
| 84 |
+
# While the structural backoff is live the gate must say blocked.
|
| 85 |
+
compressor._structural_no_op_backoff_until = time.monotonic() + 300.0
|
| 86 |
+
with patch.object(
|
| 87 |
+
compressor, "_automatic_compression_blocked", return_value=True
|
| 88 |
+
):
|
| 89 |
+
should, reason = compressor.should_compress_info(prompt_tokens=300_000)
|
| 90 |
+
assert should is False
|
| 91 |
+
assert reason is not None
|
| 92 |
+
assert reason.startswith("structural_backoff:")
|
| 93 |
+
assert compressor._compression_block_reason().startswith(
|
| 94 |
+
"structural_backoff:"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# After the backoff lapses nothing blocks: same over-threshold
|
| 98 |
+
# transcript compresses again (real gate, real state).
|
| 99 |
+
compressor._structural_no_op_backoff_until = (
|
| 100 |
+
time.monotonic() - 1.0
|
| 101 |
+
)
|
| 102 |
+
should, reason = compressor.should_compress_info(prompt_tokens=300_000)
|
| 103 |
+
assert should is True
|
| 104 |
+
assert reason is None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
SUMMARY_RESPONSE = "fresh replacement summary body"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _messages_with_old_handoff():
|
| 111 |
+
return [
|
| 112 |
+
{"role": "system", "content": "system prompt"},
|
| 113 |
+
{"role": "user", "content": (
|
| 114 |
+
"CONTEXT SUMMARY (from previous session):\nold summary body"
|
| 115 |
+
)},
|
| 116 |
+
{"role": "assistant", "content": "handoff acknowledged after resume"},
|
| 117 |
+
{"role": "user", "content": "new user turn after resume"},
|
| 118 |
+
{"role": "assistant", "content": "new assistant work after resume"},
|
| 119 |
+
{"role": "user", "content": "more new work after resume"},
|
| 120 |
+
{"role": "assistant", "content": "latest tail response"},
|
| 121 |
+
{"role": "user", "content": "final active request stays in protected tail"},
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_forced_attempt_and_success_lift_the_backoff():
|
| 126 |
+
"""Manual /compress clears an active backoff; a completed boundary lifts it.
|
| 127 |
+
|
| 128 |
+
Both are proof the transcript is being actively worked on — neither may
|
| 129 |
+
leave auto-compaction deferred by a stale structural no-op.
|
| 130 |
+
"""
|
| 131 |
+
compressor = _compressor()
|
| 132 |
+
compressor._structural_no_op_backoff_until = time.monotonic() + 300.0
|
| 133 |
+
|
| 134 |
+
with patch(
|
| 135 |
+
"agent.context_compressor.call_llm",
|
| 136 |
+
return_value=_response(SUMMARY_RESPONSE),
|
| 137 |
+
):
|
| 138 |
+
compressed = compressor.compress(
|
| 139 |
+
_messages_with_old_handoff(), force=True
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
assert compressor._structural_no_op_backoff_until == 0.0
|
| 143 |
+
# The forced attempt actually committed a boundary.
|
| 144 |
+
assert len(compressed) < len(_messages_with_old_handoff())
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_real_attempt_underperformance_still_strikes_breaker():
|
| 148 |
+
"""Only genuine attempted-but-underperformed compressions strike.
|
| 149 |
+
|
| 150 |
+
A real summary pass that saves <10% goes through the ineffective
|
| 151 |
+
verdict (persisted); structural no-ops must not touch that counter —
|
| 152 |
+
that distinction IS this fix.
|
| 153 |
+
"""
|
| 154 |
+
compressor = _compressor()
|
| 155 |
+
before = compressor._ineffective_compression_count
|
| 156 |
+
compressor._record_ineffective_compression_verdict(before + 1)
|
| 157 |
+
assert compressor._ineffective_compression_count == before + 1
|
| 158 |
+
|
| 159 |
+
compressor._record_structural_no_op("test reason")
|
| 160 |
+
assert compressor._structural_no_op_backoff_until > 0.0
|
| 161 |
+
assert compressor._ineffective_compression_count == before + 1
|
tests/agent/test_context_compressor_summary_continuity.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for iterative context-summary continuity."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, patch
|
| 4 |
+
|
| 5 |
+
from agent.context_compressor import (
|
| 6 |
+
COMPRESSED_SUMMARY_METADATA_KEY,
|
| 7 |
+
ContextCompressor,
|
| 8 |
+
SUMMARY_PREFIX,
|
| 9 |
+
_MERGED_PRIOR_CONTEXT_HEADER,
|
| 10 |
+
_MERGED_SUMMARY_DELIMITER,
|
| 11 |
+
_RESTART_HANDOFF_PROBE_EXTRA_MESSAGES,
|
| 12 |
+
_SUMMARY_END_MARKER,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _compressor(protect_first_n: int = 1) -> ContextCompressor:
|
| 17 |
+
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
| 18 |
+
return ContextCompressor(
|
| 19 |
+
model="test/model",
|
| 20 |
+
threshold_percent=0.85,
|
| 21 |
+
protect_first_n=protect_first_n,
|
| 22 |
+
protect_last_n=1,
|
| 23 |
+
quiet_mode=True,
|
| 24 |
+
# Pinned: these tests assert the stored summary ENDS with the raw
|
| 25 |
+
# LLM text. Lean mode (the default since the tail-default flip)
|
| 26 |
+
# appends the verbatim-user-quote appendix after it by design;
|
| 27 |
+
# the continuity contract under test is mode-independent.
|
| 28 |
+
tail_mode="legacy",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _response(content: str):
|
| 33 |
+
mock_response = MagicMock()
|
| 34 |
+
mock_response.choices = [MagicMock()]
|
| 35 |
+
mock_response.choices[0].message.content = content
|
| 36 |
+
return mock_response
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _messages_with_handoff(summary_body: str):
|
| 40 |
+
return [
|
| 41 |
+
{"role": "system", "content": "system prompt"},
|
| 42 |
+
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{summary_body}"},
|
| 43 |
+
{"role": "assistant", "content": "handoff acknowledged after resume"},
|
| 44 |
+
{"role": "user", "content": "new user turn after resume"},
|
| 45 |
+
{"role": "assistant", "content": "new assistant work after resume"},
|
| 46 |
+
{"role": "user", "content": "more new work after resume"},
|
| 47 |
+
{"role": "assistant", "content": "latest tail response"},
|
| 48 |
+
{"role": "user", "content": "final active request stays in protected tail"},
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _messages_with_merged_handoff(summary_body: str, prior_tail: str):
|
| 53 |
+
merged = {
|
| 54 |
+
"role": "user",
|
| 55 |
+
"content": (
|
| 56 |
+
f"{_MERGED_PRIOR_CONTEXT_HEADER}\n{prior_tail}\n\n"
|
| 57 |
+
f"{_MERGED_SUMMARY_DELIMITER}\n\n"
|
| 58 |
+
f"{SUMMARY_PREFIX}\n{summary_body}\n\n{_SUMMARY_END_MARKER}"
|
| 59 |
+
),
|
| 60 |
+
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
| 61 |
+
}
|
| 62 |
+
messages = _messages_with_handoff(summary_body)
|
| 63 |
+
messages[1] = merged
|
| 64 |
+
return messages
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _messages_with_default_handoff(summary_body: str):
|
| 68 |
+
return [
|
| 69 |
+
{"role": "system", "content": "system prompt"},
|
| 70 |
+
{"role": "user", "content": "original task before first compaction"},
|
| 71 |
+
{"role": "assistant", "content": "original answer before first compaction"},
|
| 72 |
+
{"role": "user", "content": "original follow-up before first compaction"},
|
| 73 |
+
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{summary_body}"},
|
| 74 |
+
{"role": "user", "content": "new user turn after restart"},
|
| 75 |
+
{"role": "assistant", "content": "new assistant work after restart"},
|
| 76 |
+
{"role": "user", "content": "more new work after restart"},
|
| 77 |
+
{"role": "assistant", "content": "latest tail response"},
|
| 78 |
+
{"role": "user", "content": "final active request stays in protected tail"},
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _messages_with_summary_at_index(summary_index: int):
|
| 83 |
+
msgs = [{"role": "system", "content": "system prompt"}]
|
| 84 |
+
for idx in range(1, summary_index):
|
| 85 |
+
role = "user" if idx % 2 else "assistant"
|
| 86 |
+
msgs.append({"role": role, "content": f"probe filler {idx}"})
|
| 87 |
+
role = "user" if summary_index % 2 else "assistant"
|
| 88 |
+
msgs.append({"role": role, "content": f"{SUMMARY_PREFIX}\nboundary summary"})
|
| 89 |
+
msgs.extend([
|
| 90 |
+
{"role": "assistant", "content": "new answer"},
|
| 91 |
+
{"role": "user", "content": "tail request"},
|
| 92 |
+
])
|
| 93 |
+
return msgs
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_handoff_in_protected_head_is_replaced_not_duplicated():
|
| 103 |
+
"""Re-compaction must replace a protected old handoff with the updated one."""
|
| 104 |
+
compressor = _compressor()
|
| 105 |
+
old_summary = "OLD-PROTECTED-HANDOFF unique old summary body"
|
| 106 |
+
|
| 107 |
+
with patch("agent.context_compressor.call_llm", return_value=_response("UPDATED summary body")):
|
| 108 |
+
compressed = compressor.compress(_messages_with_handoff(old_summary))
|
| 109 |
+
|
| 110 |
+
# The summary may be emitted standalone or merged into the first tail
|
| 111 |
+
# message (alternation corner case), so detect it the same way the
|
| 112 |
+
# compressor does rather than via a startswith(SUMMARY_PREFIX) check.
|
| 113 |
+
summary_messages = [
|
| 114 |
+
msg
|
| 115 |
+
for msg in compressed
|
| 116 |
+
if isinstance(msg, dict)
|
| 117 |
+
and ContextCompressor._is_context_summary_content(msg.get("content"))
|
| 118 |
+
]
|
| 119 |
+
assert len(summary_messages) == 1
|
| 120 |
+
assert "UPDATED summary body" in str(summary_messages[0]["content"])
|
| 121 |
+
assert old_summary not in str(summary_messages[0]["content"])
|
| 122 |
+
assert old_summary not in "\n".join(str(msg.get("content") or "") for msg in compressed)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_recompression_of_current_merged_handoff_preserves_prior_tail_once():
|
| 130 |
+
"""Current merged handoffs lose only stale summary data on recompression.
|
| 131 |
+
|
| 132 |
+
Composed contract after #57835 (restart head-protection decay): the
|
| 133 |
+
merged handoff's genuine prior-tail content must be RECOVERED — either
|
| 134 |
+
verbatim in the output (pre-decay head protection) or by entering the
|
| 135 |
+
summarizer input so the fresh summary folds it in (post-decay). It must
|
| 136 |
+
never be silently deleted, and the stale summary body must never be
|
| 137 |
+
re-emitted verbatim.
|
| 138 |
+
"""
|
| 139 |
+
compressor = _compressor()
|
| 140 |
+
old_summary = "CURRENT-MERGED-OLD-SUMMARY unique continuity facts"
|
| 141 |
+
prior_tail = "PRESERVED-PRIOR-TAIL real user content"
|
| 142 |
+
|
| 143 |
+
seen_turns = []
|
| 144 |
+
|
| 145 |
+
def _capture(turns, **kwargs):
|
| 146 |
+
seen_turns.extend(turns)
|
| 147 |
+
return ContextCompressor._with_summary_prefix(
|
| 148 |
+
"fresh replacement summary"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
with patch.object(
|
| 152 |
+
compressor,
|
| 153 |
+
"_generate_summary",
|
| 154 |
+
side_effect=_capture,
|
| 155 |
+
):
|
| 156 |
+
result = compressor.compress(
|
| 157 |
+
_messages_with_merged_handoff(old_summary, prior_tail)
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
joined = "\n".join(str(message.get("content", "")) for message in result)
|
| 161 |
+
summarizer_input = "\n".join(str(t.get("content", "")) for t in seen_turns)
|
| 162 |
+
# Prior tail recovered: verbatim in output OR folded via summarizer input.
|
| 163 |
+
assert prior_tail in joined or prior_tail in summarizer_input
|
| 164 |
+
# Never duplicated in the output.
|
| 165 |
+
assert joined.count(prior_tail) <= 1
|
| 166 |
+
assert old_summary not in joined
|
| 167 |
+
assert joined.count(SUMMARY_PREFIX) == 1
|
| 168 |
+
assert "fresh replacement summary" in joined
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def test_resume_handoff_after_default_protected_head_decays_initial_turns():
|
| 178 |
+
"""Default protect_first_n=3 should not fossilize old protected head turns."""
|
| 179 |
+
compressor = _compressor(protect_first_n=3)
|
| 180 |
+
old_summary = "DEFAULT-RESTART-SUMMARY durable facts from before restart"
|
| 181 |
+
|
| 182 |
+
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")) as mock_call:
|
| 183 |
+
result = compressor.compress(_messages_with_default_handoff(old_summary))
|
| 184 |
+
|
| 185 |
+
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
| 186 |
+
assert "PREVIOUS SUMMARY:" in prompt
|
| 187 |
+
assert prompt.count(old_summary) == 1
|
| 188 |
+
assert "original task before first compaction" in prompt
|
| 189 |
+
assert "original answer before first compaction" in prompt
|
| 190 |
+
assert "original follow-up before first compaction" in prompt
|
| 191 |
+
assert f"[ASSISTANT]: {SUMMARY_PREFIX}" not in prompt
|
| 192 |
+
# Grounding (761a0b124e) may prepend a deterministic task-snapshot
|
| 193 |
+
# section — pin the contract, not the exact stored string.
|
| 194 |
+
stored_summary = compressor._previous_summary or ""
|
| 195 |
+
assert stored_summary.endswith("fresh summary")
|
| 196 |
+
assert old_summary not in stored_summary
|
| 197 |
+
assert all(
|
| 198 |
+
"original task before first compaction" not in str(msg.get("content", ""))
|
| 199 |
+
for msg in result
|
| 200 |
+
)
|
| 201 |
+
assert all(
|
| 202 |
+
"original answer before first compaction" not in str(msg.get("content", ""))
|
| 203 |
+
for msg in result
|
| 204 |
+
)
|
| 205 |
+
assert all(
|
| 206 |
+
old_summary not in str(msg.get("content", ""))
|
| 207 |
+
for msg in result
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def test_restart_simulation_fresh_compressor_does_not_reprotect_head():
|
| 212 |
+
"""Gateway-restart simulation: a FRESH ContextCompressor (in-memory decay
|
| 213 |
+
state reset — compression_count == 0, _previous_summary is None) over a
|
| 214 |
+
transcript that contains a persisted handoff summary must NOT re-protect
|
| 215 |
+
the head. compress_start must reflect decayed protection exactly as a
|
| 216 |
+
live (non-restarted) process would compute it (#57814)."""
|
| 217 |
+
# Live process: has already compacted once, decay is in-memory.
|
| 218 |
+
live = _compressor(protect_first_n=3)
|
| 219 |
+
live.compression_count = 1
|
| 220 |
+
|
| 221 |
+
# Restarted process: brand-new compressor, all in-memory state fresh.
|
| 222 |
+
restarted = _compressor(protect_first_n=3)
|
| 223 |
+
assert restarted.compression_count == 0
|
| 224 |
+
assert not restarted._previous_summary
|
| 225 |
+
|
| 226 |
+
msgs = _messages_with_default_handoff(
|
| 227 |
+
"PERSISTED-HANDOFF durable facts from before restart"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
# The protected-head boundary the compressor uses for compress_start
|
| 231 |
+
# must be identical for both: system prompt only (decayed protection).
|
| 232 |
+
assert restarted._effective_protect_first_n(msgs) == 0
|
| 233 |
+
assert restarted._protect_head_size(msgs) == live._protect_head_size(msgs) == 1
|
| 234 |
+
restarted_start = restarted._align_boundary_forward(
|
| 235 |
+
msgs, restarted._protect_head_size(msgs)
|
| 236 |
+
)
|
| 237 |
+
assert restarted_start == 1
|
| 238 |
+
|
| 239 |
+
# End-to-end: the first post-restart compaction must not preserve the
|
| 240 |
+
# pre-restart head turns or the old handoff verbatim.
|
| 241 |
+
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")):
|
| 242 |
+
result = restarted.compress(msgs)
|
| 243 |
+
result_text = "\n".join(str(msg.get("content", "")) for msg in result)
|
| 244 |
+
assert "PERSISTED-HANDOFF durable facts" not in result_text
|
| 245 |
+
assert "original task before first compaction" not in result_text
|
| 246 |
+
assert "original answer before first compaction" not in result_text
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def test_zero_protect_first_n_still_folds_restart_fossil():
|
| 256 |
+
"""protect_first_n=0 should still self-heal restarted summaries."""
|
| 257 |
+
compressor = _compressor(protect_first_n=0)
|
| 258 |
+
old_summary = "OLD-SUMMARY-ZERO-PROTECT durable facts"
|
| 259 |
+
msgs = [
|
| 260 |
+
{"role": "system", "content": "system prompt"},
|
| 261 |
+
{"role": "user", "content": "task one"},
|
| 262 |
+
{"role": "assistant", "content": "answer one"},
|
| 263 |
+
{"role": "user", "content": "task two"},
|
| 264 |
+
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"},
|
| 265 |
+
{"role": "user", "content": "active request"},
|
| 266 |
+
]
|
| 267 |
+
|
| 268 |
+
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")):
|
| 269 |
+
result = compressor.compress(msgs)
|
| 270 |
+
|
| 271 |
+
result_text = "\n".join(str(msg.get("content", "")) for msg in result)
|
| 272 |
+
assert old_summary not in result_text
|
| 273 |
+
assert result_text.index(_SUMMARY_END_MARKER) < result_text.index("active request")
|
| 274 |
+
assert sum(
|
| 275 |
+
1 for msg in result if ContextCompressor._is_context_summary_message(msg)
|
| 276 |
+
) == 1
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def test_restart_fossil_survives_summary_abort_then_retry():
|
| 282 |
+
"""An aborted first compaction must not strand the rehydrated fossil.
|
| 283 |
+
|
| 284 |
+
Regression for the abort/retry path. The first-compaction self-heal scan
|
| 285 |
+
(``compression_count < 1``) populates ``_previous_summary`` from a fossil
|
| 286 |
+
that drifted past the decay probe. If summary generation then aborts
|
| 287 |
+
(auth / network / ``abort_on_summary_failure``) and returns the transcript
|
| 288 |
+
unchanged, the aborted attempt must not leave that rehydrated state behind:
|
| 289 |
+
otherwise the retry — still ``compression_count == 0`` but now with a
|
| 290 |
+
truthy ``_previous_summary`` — takes the narrow rescan, misses the
|
| 291 |
+
beyond-window fossil, and then discards the rehydrated summary as
|
| 292 |
+
cross-session leakage, copying the fossil forward as a stacked summary.
|
| 293 |
+
"""
|
| 294 |
+
compressor = _compressor(protect_first_n=1)
|
| 295 |
+
compressor.abort_on_summary_failure = True
|
| 296 |
+
old_summary = "ABORT-RETRY-OLD-SUMMARY durable facts"
|
| 297 |
+
msgs = [{"role": "system", "content": "system prompt"}]
|
| 298 |
+
msgs += [
|
| 299 |
+
{
|
| 300 |
+
"role": "user" if idx % 2 else "assistant",
|
| 301 |
+
"content": f"filler {idx}",
|
| 302 |
+
}
|
| 303 |
+
for idx in range(1, 6)
|
| 304 |
+
]
|
| 305 |
+
msgs += [
|
| 306 |
+
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"},
|
| 307 |
+
{"role": "user", "content": "active request"},
|
| 308 |
+
]
|
| 309 |
+
|
| 310 |
+
# First compaction aborts on a summary-generation failure. The transcript
|
| 311 |
+
# is returned unchanged AND the self-heal state it rehydrated must be
|
| 312 |
+
# rolled back, so a retry behaves like the original first compaction.
|
| 313 |
+
with patch.object(compressor, "_generate_summary", return_value=None):
|
| 314 |
+
aborted = compressor.compress([dict(m) for m in msgs])
|
| 315 |
+
assert compressor._last_compress_aborted is True
|
| 316 |
+
assert all(m["content"] for m in aborted) # returned unchanged
|
| 317 |
+
assert compressor.compression_count == 0
|
| 318 |
+
assert compressor._previous_summary is None
|
| 319 |
+
|
| 320 |
+
# Retry: the fossil beyond the narrow window is still folded, not copied
|
| 321 |
+
# forward as a second stacked summary.
|
| 322 |
+
with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")):
|
| 323 |
+
result = compressor.compress([dict(m) for m in msgs])
|
| 324 |
+
|
| 325 |
+
assert all(old_summary not in str(msg.get("content", "")) for msg in result)
|
| 326 |
+
assert sum(
|
| 327 |
+
1 for msg in result if ContextCompressor._is_context_summary_message(msg)
|
| 328 |
+
) == 1
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def test_degenerate_compress_end_keeps_same_session_previous_summary():
|
| 332 |
+
"""Handoff beyond a degenerate compress_end must not clear same-session state.
|
| 333 |
+
|
| 334 |
+
Regression for #83248: with compression_count >= 1 and a truthy
|
| 335 |
+
``_previous_summary`` from this session's prior compaction, a scan bounded
|
| 336 |
+
at ``compress_end`` can miss an in-window handoff sitting past the cut.
|
| 337 |
+
The #57835 cross-session guard then discarded the valid summary and the
|
| 338 |
+
from-scratch path replaced a rich handoff with a one-turn summary.
|
| 339 |
+
"""
|
| 340 |
+
compressor = _compressor(protect_first_n=0)
|
| 341 |
+
rich_summary = (
|
| 342 |
+
"RICH-SAME-SESSION-HANDOFF\n"
|
| 343 |
+
"1. completed action alpha\n"
|
| 344 |
+
"2. completed action beta\n"
|
| 345 |
+
"3. completed action gamma"
|
| 346 |
+
)
|
| 347 |
+
compressor.compression_count = 1
|
| 348 |
+
compressor._previous_summary = rich_summary
|
| 349 |
+
|
| 350 |
+
messages = [
|
| 351 |
+
{"role": "system", "content": "system prompt"},
|
| 352 |
+
{"role": "user", "content": "early turn before cut"},
|
| 353 |
+
{"role": "assistant", "content": "early answer"},
|
| 354 |
+
{"role": "user", "content": "mid turn before handoff"},
|
| 355 |
+
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{rich_summary}"},
|
| 356 |
+
{"role": "user", "content": "post handoff turn"},
|
| 357 |
+
{"role": "assistant", "content": "post handoff answer"},
|
| 358 |
+
{"role": "user", "content": "tail request"},
|
| 359 |
+
]
|
| 360 |
+
# Handoff is at index 4; force compress_end=2 so a compress_end-bounded
|
| 361 |
+
# scan would miss it while still leaving a non-empty middle window.
|
| 362 |
+
assert ContextCompressor._is_context_summary_message(messages[4])
|
| 363 |
+
|
| 364 |
+
previous_at_generate = []
|
| 365 |
+
|
| 366 |
+
def _capture(turns, **kwargs):
|
| 367 |
+
previous_at_generate.append(compressor._previous_summary)
|
| 368 |
+
return ContextCompressor._with_summary_prefix("updated iterative summary")
|
| 369 |
+
|
| 370 |
+
with (
|
| 371 |
+
patch.object(compressor, "_find_tail_cut_by_tokens", return_value=2),
|
| 372 |
+
patch.object(compressor, "_generate_summary", side_effect=_capture),
|
| 373 |
+
):
|
| 374 |
+
result = compressor.compress(messages, current_tokens=90_000)
|
| 375 |
+
|
| 376 |
+
assert previous_at_generate, "_generate_summary should have run"
|
| 377 |
+
assert previous_at_generate[0] is not None, (
|
| 378 |
+
"same-session _previous_summary must not be discarded when a handoff "
|
| 379 |
+
"exists beyond a degenerate compress_end"
|
| 380 |
+
)
|
| 381 |
+
assert "RICH-SAME-SESSION-HANDOFF" in previous_at_generate[0]
|
| 382 |
+
# Iterative state must remain available after the attempt (either the
|
| 383 |
+
# prior rich handoff, or the updated summary if the compaction committed).
|
| 384 |
+
assert compressor._previous_summary is not None
|
| 385 |
+
stored = compressor._previous_summary or ""
|
| 386 |
+
assert (
|
| 387 |
+
"RICH-SAME-SESSION-HANDOFF" in stored
|
| 388 |
+
or "updated iterative summary" in stored
|
| 389 |
+
)
|
| 390 |
+
assert sum(
|
| 391 |
+
1 for msg in result if ContextCompressor._is_context_summary_message(msg)
|
| 392 |
+
) >= 1
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def test_forced_leading_merged_summary_strips_live_tail_from_summary_body():
|
| 398 |
+
"""Rehydrating a forced-leading merged summary should ignore live tail."""
|
| 399 |
+
merged = (
|
| 400 |
+
f"{SUMMARY_PREFIX}\nSUMMARY_BODY\n\n"
|
| 401 |
+
f"{_SUMMARY_END_MARKER}\n\n"
|
| 402 |
+
"LIVE_TAIL_REQUEST"
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
assert ContextCompressor._is_context_summary_content(merged) is True
|
| 406 |
+
assert ContextCompressor._strip_summary_prefix(merged) == "SUMMARY_BODY"
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def test_empty_post_handoff_window_noops_without_summary_call():
|
| 418 |
+
"""A latest handoff that consumes the window must not trigger an empty summary.
|
| 419 |
+
|
| 420 |
+
Regression test from PR #59526 (#59496), fixture adapted to current main:
|
| 421 |
+
the standalone handoff sits alone in the compressible window, strips to
|
| 422 |
+
None via _strip_context_summary_handoff_message, and leaves
|
| 423 |
+
turns_to_summarize empty — the guard must skip _generate_summary
|
| 424 |
+
entirely instead of wasting an aux LLM call on empty input.
|
| 425 |
+
"""
|
| 426 |
+
compressor = _compressor()
|
| 427 |
+
old_summary = "WINDOW-END-SUMMARY durable facts already captured"
|
| 428 |
+
messages = [
|
| 429 |
+
{"role": "system", "content": "system prompt"},
|
| 430 |
+
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{old_summary}"},
|
| 431 |
+
{"role": "assistant", "content": "recent tail response"},
|
| 432 |
+
{"role": "user", "content": "tail request"},
|
| 433 |
+
{"role": "assistant", "content": "tail answer"},
|
| 434 |
+
{"role": "user", "content": "latest tail request"},
|
| 435 |
+
{"role": "assistant", "content": "latest tail answer"},
|
| 436 |
+
]
|
| 437 |
+
|
| 438 |
+
with (
|
| 439 |
+
patch.object(compressor, "_find_tail_cut_by_tokens", return_value=2),
|
| 440 |
+
patch.object(compressor, "_generate_summary") as mock_generate_summary,
|
| 441 |
+
):
|
| 442 |
+
result = compressor.compress(messages, current_tokens=90_000)
|
| 443 |
+
|
| 444 |
+
mock_generate_summary.assert_not_called()
|
| 445 |
+
assert result == messages
|
| 446 |
+
# The rehydrated summary state is deliberately kept: the handoff is
|
| 447 |
+
# genuinely present in the returned (unchanged) transcript.
|
| 448 |
+
assert compressor._previous_summary == old_summary
|
| 449 |
+
assert compressor.compression_count == 0
|
| 450 |
+
# Mirrors the sibling no-compressible-window guard, but as a structural
|
| 451 |
+
# no-op (#93022): the window holds nothing eligible to compress, so the
|
| 452 |
+
# compressor defers retries transiently instead of arming the permanent
|
| 453 |
+
# anti-thrash breaker.
|
| 454 |
+
assert compressor._ineffective_compression_count == 0
|
| 455 |
+
assert compressor._structural_no_op_backoff_until > 0.0
|
| 456 |
+
assert compressor._last_compression_savings_pct == 0.0
|
| 457 |
+
assert compressor._last_summary_dropped_count == 0
|
| 458 |
+
assert compressor._last_summary_fallback_used is False
|
| 459 |
+
assert compressor._last_compress_aborted is False
|
| 460 |
+
telemetry = compressor._last_compression_telemetry or {}
|
| 461 |
+
assert telemetry.get("failure_class") == "empty_post_handoff_window"
|
tests/agent/test_context_compressor_zero_user_provenance.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression coverage for zero-user compaction integrity (#64539)."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
from unittest.mock import patch
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from agent.context_compressor import (
|
| 10 |
+
COMPRESSION_CONTINUATION_USER_CONTENT,
|
| 11 |
+
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY,
|
| 12 |
+
COMPRESSED_SUMMARY_METADATA_KEY,
|
| 13 |
+
HISTORICAL_TASK_HEADING,
|
| 14 |
+
MAX_ITERATIONS_SUMMARY_REQUEST,
|
| 15 |
+
SUMMARY_PREFIX,
|
| 16 |
+
ContextCompressor,
|
| 17 |
+
_NO_USER_TASK_SENTINEL,
|
| 18 |
+
)
|
| 19 |
+
from agent.conversation_compression import (
|
| 20 |
+
_ensure_compressed_has_user_turn,
|
| 21 |
+
compress_context,
|
| 22 |
+
)
|
| 23 |
+
from hermes_state import SessionDB
|
| 24 |
+
from tools.process_registry_notifications import format_process_notification
|
| 25 |
+
from tools.todo_tool import TODO_INJECTION_HEADER
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _response(content: str) -> SimpleNamespace:
|
| 29 |
+
return SimpleNamespace(
|
| 30 |
+
choices=[SimpleNamespace(message=SimpleNamespace(content=content))]
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _valid_zero_user_summary(label: str = "Checked artifacts.") -> str:
|
| 35 |
+
return f"""{HISTORICAL_TASK_HEADING}
|
| 36 |
+
{_NO_USER_TASK_SENTINEL}
|
| 37 |
+
|
| 38 |
+
## Goal
|
| 39 |
+
Historical cron work only.
|
| 40 |
+
|
| 41 |
+
## Completed Actions
|
| 42 |
+
1. {label}
|
| 43 |
+
|
| 44 |
+
## Resolved Questions
|
| 45 |
+
None. No user-authored questions exist.
|
| 46 |
+
|
| 47 |
+
## Historical Pending User Asks
|
| 48 |
+
None. No user-authored requests exist.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _assistant_tool_turns(start: int, count: int) -> list[dict]:
|
| 53 |
+
turns: list[dict] = []
|
| 54 |
+
for idx in range(start, start + count):
|
| 55 |
+
turns.extend(
|
| 56 |
+
[
|
| 57 |
+
{
|
| 58 |
+
"role": "assistant",
|
| 59 |
+
"content": "Continuing scheduled work in English.",
|
| 60 |
+
"tool_calls": [
|
| 61 |
+
{
|
| 62 |
+
"id": f"call-{idx}",
|
| 63 |
+
"function": {
|
| 64 |
+
"name": "terminal",
|
| 65 |
+
"arguments": '{"command":"pwd"}',
|
| 66 |
+
},
|
| 67 |
+
}
|
| 68 |
+
],
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"role": "tool",
|
| 72 |
+
"tool_call_id": f"call-{idx}",
|
| 73 |
+
"content": "/workspace/project\n" + ("x" * 300),
|
| 74 |
+
},
|
| 75 |
+
]
|
| 76 |
+
)
|
| 77 |
+
return turns
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _assistant_turns(start: int, count: int) -> list[dict]:
|
| 81 |
+
return [
|
| 82 |
+
{
|
| 83 |
+
"role": "assistant",
|
| 84 |
+
"content": f"Scheduled step {idx} completed. " + ("x" * 500),
|
| 85 |
+
}
|
| 86 |
+
for idx in range(start, start + count)
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _lifecycle_agent(db: SessionDB, session_id: str):
|
| 91 |
+
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
|
| 92 |
+
from run_agent import AIAgent
|
| 93 |
+
|
| 94 |
+
agent = AIAgent(
|
| 95 |
+
api_key="test-key",
|
| 96 |
+
base_url="https://openrouter.ai/api/v1",
|
| 97 |
+
model="test/model",
|
| 98 |
+
quiet_mode=True,
|
| 99 |
+
session_db=db,
|
| 100 |
+
session_id=session_id,
|
| 101 |
+
skip_context_files=True,
|
| 102 |
+
skip_memory=True,
|
| 103 |
+
)
|
| 104 |
+
agent.compression_in_place = True
|
| 105 |
+
agent.context_compressor.protect_first_n = 0
|
| 106 |
+
agent.context_compressor.protect_last_n = 2
|
| 107 |
+
agent.context_compressor.tail_token_budget = 80
|
| 108 |
+
agent._todo_store.write(
|
| 109 |
+
[{"id": "inspect", "content": "Inspect artifacts", "status": "pending"}]
|
| 110 |
+
)
|
| 111 |
+
return agent
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@pytest.fixture()
|
| 115 |
+
def compressor() -> ContextCompressor:
|
| 116 |
+
with patch(
|
| 117 |
+
"agent.context_compressor.get_model_context_length",
|
| 118 |
+
return_value=100_000,
|
| 119 |
+
):
|
| 120 |
+
instance = ContextCompressor(
|
| 121 |
+
model="test/model",
|
| 122 |
+
threshold_percent=0.50,
|
| 123 |
+
protect_first_n=0,
|
| 124 |
+
protect_last_n=2,
|
| 125 |
+
quiet_mode=True,
|
| 126 |
+
)
|
| 127 |
+
instance.tail_token_budget = 80
|
| 128 |
+
return instance
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_generate_summary_rejects_fabricated_user_ask(compressor):
|
| 132 |
+
fabricated = f"""{HISTORICAL_TASK_HEADING}
|
| 133 |
+
User asked: 'Waar zijn de bestanden gedownload?'
|
| 134 |
+
|
| 135 |
+
## Goal
|
| 136 |
+
Vind de bestanden.
|
| 137 |
+
"""
|
| 138 |
+
|
| 139 |
+
with patch(
|
| 140 |
+
"agent.context_compressor.call_llm",
|
| 141 |
+
return_value=_response(fabricated),
|
| 142 |
+
):
|
| 143 |
+
result = compressor._generate_summary(_assistant_tool_turns(0, 2))
|
| 144 |
+
|
| 145 |
+
assert result is None
|
| 146 |
+
assert compressor._previous_summary is None
|
| 147 |
+
assert "invented user attribution" in compressor._last_summary_error
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_zero_user_provenance_survives_iterative_compaction(compressor):
|
| 153 |
+
messages = _assistant_tool_turns(0, 12)
|
| 154 |
+
first_summary = f"{SUMMARY_PREFIX}\n{_valid_zero_user_summary('First pass').strip()}"
|
| 155 |
+
|
| 156 |
+
with patch.object(compressor, "_generate_summary", return_value=first_summary):
|
| 157 |
+
first = compressor.compress(messages, current_tokens=90_000)
|
| 158 |
+
|
| 159 |
+
first_handoffs = [
|
| 160 |
+
message
|
| 161 |
+
for message in first
|
| 162 |
+
if message.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
| 163 |
+
]
|
| 164 |
+
assert len(first_handoffs) == 1
|
| 165 |
+
assert first_handoffs[0][COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False
|
| 166 |
+
|
| 167 |
+
with patch(
|
| 168 |
+
"agent.context_compressor.get_model_context_length",
|
| 169 |
+
return_value=100_000,
|
| 170 |
+
):
|
| 171 |
+
resumed = ContextCompressor(
|
| 172 |
+
model="test/model",
|
| 173 |
+
threshold_percent=0.50,
|
| 174 |
+
protect_first_n=0,
|
| 175 |
+
protect_last_n=2,
|
| 176 |
+
quiet_mode=True,
|
| 177 |
+
)
|
| 178 |
+
resumed.tail_token_budget = 80
|
| 179 |
+
# SessionDB persists the summary content/role but not arbitrary internal
|
| 180 |
+
# message keys. Simulate that round trip: the exact sentinel must recover
|
| 181 |
+
# false provenance even when both in-process metadata keys are absent.
|
| 182 |
+
persisted_handoff = dict(first_handoffs[0])
|
| 183 |
+
persisted_handoff.pop(COMPRESSED_SUMMARY_METADATA_KEY)
|
| 184 |
+
persisted_handoff.pop(COMPRESSED_SUMMARY_HAS_USER_TURN_KEY)
|
| 185 |
+
second_input = [persisted_handoff, *_assistant_tool_turns(20, 12)]
|
| 186 |
+
|
| 187 |
+
def assert_provenance_then_summarize(*_args, **_kwargs):
|
| 188 |
+
assert resumed._summary_has_user_turn is False
|
| 189 |
+
return f"{SUMMARY_PREFIX}\n{_valid_zero_user_summary('Second pass').strip()}"
|
| 190 |
+
|
| 191 |
+
with patch.object(
|
| 192 |
+
resumed,
|
| 193 |
+
"_generate_summary",
|
| 194 |
+
side_effect=assert_provenance_then_summarize,
|
| 195 |
+
):
|
| 196 |
+
second = resumed.compress(second_input, current_tokens=90_000)
|
| 197 |
+
|
| 198 |
+
second_handoffs = [
|
| 199 |
+
message
|
| 200 |
+
for message in second
|
| 201 |
+
if message.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
| 202 |
+
]
|
| 203 |
+
assert len(second_handoffs) == 1
|
| 204 |
+
assert second_handoffs[0][COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def test_max_iterations_nudge_is_synthetic_not_actionable():
|
| 208 |
+
"""#78580: the max-iteration runtime nudge is runtime scaffolding, not a
|
| 209 |
+
human turn. It is appended as ``role="user"`` and persisted verbatim in
|
| 210 |
+
state.db (metadata flags do not survive projection), so recognition must be
|
| 211 |
+
content-based — exactly like the continuation/todo markers."""
|
| 212 |
+
# The projected form: a bare role/content row with no internal metadata.
|
| 213 |
+
nudge = {"role": "user", "content": MAX_ITERATIONS_SUMMARY_REQUEST}
|
| 214 |
+
|
| 215 |
+
assert ContextCompressor._is_synthetic_compression_user_turn(nudge) is True
|
| 216 |
+
# A real human turn with the same shape stays actionable.
|
| 217 |
+
human = {"role": "user", "content": "Ship the release notes for v2."}
|
| 218 |
+
assert ContextCompressor._is_synthetic_compression_user_turn(human) is False
|
| 219 |
+
assert ContextCompressor._transcript_has_real_user_turn([nudge]) is False
|
| 220 |
+
assert ContextCompressor._transcript_has_real_user_turn([human, nudge]) is True
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def test_real_task_wins_over_trailing_max_iterations_nudge(compressor):
|
| 224 |
+
"""The tail anchor must resolve to the human task, not the nudge that the
|
| 225 |
+
runtime appended after it when iterations were exhausted."""
|
| 226 |
+
human = {"role": "user", "content": "Refactor the auth module and add tests."}
|
| 227 |
+
messages = [
|
| 228 |
+
human,
|
| 229 |
+
{"role": "assistant", "content": "Working on it.", "tool_calls": [
|
| 230 |
+
{"id": "c1", "function": {"name": "terminal", "arguments": "{}"}}
|
| 231 |
+
]},
|
| 232 |
+
{"role": "tool", "tool_call_id": "c1", "content": "ok"},
|
| 233 |
+
{"role": "user", "content": MAX_ITERATIONS_SUMMARY_REQUEST},
|
| 234 |
+
]
|
| 235 |
+
|
| 236 |
+
idx = compressor._find_last_user_message_idx(messages, head_end=0)
|
| 237 |
+
assert idx == 0, "nudge was selected as the anchor instead of the human task"
|
| 238 |
+
assert messages[idx]["content"] == human["content"]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@pytest.mark.parametrize(
|
| 242 |
+
"event",
|
| 243 |
+
[
|
| 244 |
+
pytest.param(
|
| 245 |
+
{
|
| 246 |
+
"type": "completion",
|
| 247 |
+
"session_id": "proc_build",
|
| 248 |
+
"command": "scripts/run_tests.sh tests/agent/",
|
| 249 |
+
"exit_code": 0,
|
| 250 |
+
"output": "42 passed",
|
| 251 |
+
},
|
| 252 |
+
id="completion",
|
| 253 |
+
),
|
| 254 |
+
pytest.param(
|
| 255 |
+
{
|
| 256 |
+
"type": "watch_match",
|
| 257 |
+
"session_id": "proc_server",
|
| 258 |
+
"command": "python server.py",
|
| 259 |
+
"pattern": "Application startup complete",
|
| 260 |
+
"output": "Application startup complete",
|
| 261 |
+
},
|
| 262 |
+
id="watch_match",
|
| 263 |
+
),
|
| 264 |
+
],
|
| 265 |
+
)
|
| 266 |
+
def test_background_process_notifications_do_not_become_compaction_anchors(
|
| 267 |
+
compressor, event
|
| 268 |
+
):
|
| 269 |
+
notification = format_process_notification(event)
|
| 270 |
+
assert notification is not None
|
| 271 |
+
process_turn = {"role": "user", "content": notification}
|
| 272 |
+
human = {"role": "user", "content": "Refactor the auth module and add tests."}
|
| 273 |
+
messages = [
|
| 274 |
+
human,
|
| 275 |
+
{"role": "assistant", "content": "Working on it."},
|
| 276 |
+
process_turn,
|
| 277 |
+
]
|
| 278 |
+
|
| 279 |
+
assert ContextCompressor._is_synthetic_compression_user_turn(process_turn) is True
|
| 280 |
+
assert ContextCompressor._transcript_has_real_user_turn([process_turn]) is False
|
| 281 |
+
assert compressor._derive_auto_focus_topic(messages) == (
|
| 282 |
+
"Recent user focus:\n- Refactor the auth module and add tests."
|
| 283 |
+
)
|
| 284 |
+
assert compressor._find_last_user_message_idx(messages, head_end=0) == 0
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@pytest.mark.parametrize(
|
| 288 |
+
"content",
|
| 289 |
+
[
|
| 290 |
+
pytest.param(
|
| 291 |
+
"[System: The previous response was cut off by a "
|
| 292 |
+
"network error mid-stream. Continue exactly where "
|
| 293 |
+
"you left off. Do not restart or repeat prior text. "
|
| 294 |
+
"Finish the answer directly.]",
|
| 295 |
+
id="length_continuation_network_stub",
|
| 296 |
+
),
|
| 297 |
+
pytest.param(
|
| 298 |
+
"[System: Your previous response was truncated by the output "
|
| 299 |
+
"length limit. Continue exactly where you left off. Do not "
|
| 300 |
+
"restart or repeat prior text. Finish the answer directly.]",
|
| 301 |
+
id="length_continuation_output_limit",
|
| 302 |
+
),
|
| 303 |
+
pytest.param(
|
| 304 |
+
"[System: Your previous tool call (write_file) was too large and "
|
| 305 |
+
"the stream timed out before it could be delivered. Do NOT retry "
|
| 306 |
+
"the same tool call with the same large content. Instead, break the "
|
| 307 |
+
"content into multiple smaller tool calls (e.g. use multiple patch "
|
| 308 |
+
"calls or write smaller files). Each tool call's arguments must be "
|
| 309 |
+
"under ~8K tokens to avoid stream timeouts.]",
|
| 310 |
+
id="length_continuation_dropped_tools",
|
| 311 |
+
),
|
| 312 |
+
pytest.param(
|
| 313 |
+
"[System: Your previous response contained only internal reasoning and "
|
| 314 |
+
"never produced a visible answer or tool call. Do not keep thinking. "
|
| 315 |
+
"Produce your final answer as plain text now (or make the tool call "
|
| 316 |
+
"you were planning).]",
|
| 317 |
+
id="codex_incomplete_nudge",
|
| 318 |
+
),
|
| 319 |
+
pytest.param(
|
| 320 |
+
"[System: Continue now. Execute the required tool calls and only "
|
| 321 |
+
"send your final answer after completing the task.]",
|
| 322 |
+
id="codex_ack_continuation_nudge",
|
| 323 |
+
),
|
| 324 |
+
pytest.param(
|
| 325 |
+
"Your previous turn indicated a tool call but none was "
|
| 326 |
+
"included. Do not narrate a plan or restate intent — issue "
|
| 327 |
+
"the actual tool call now to continue the task.",
|
| 328 |
+
id="dropped_toolcall_nudge",
|
| 329 |
+
),
|
| 330 |
+
pytest.param(
|
| 331 |
+
"You just executed tool calls but returned an "
|
| 332 |
+
"empty response. Please process the tool "
|
| 333 |
+
"results above and continue with the task.",
|
| 334 |
+
id="empty_tool_response_nudge",
|
| 335 |
+
),
|
| 336 |
+
],
|
| 337 |
+
)
|
| 338 |
+
def test_conversation_loop_retry_nudges_are_synthetic(content):
|
| 339 |
+
"""These are runtime recovery nudges appended by conversation_loop's retry
|
| 340 |
+
loop (length-continuation, codex incomplete/ack-continuation,
|
| 341 |
+
dropped-tool-call) — same "ephemeral scaffolding, not a human turn" class
|
| 342 |
+
as MAX_ITERATIONS_SUMMARY_REQUEST above. A turn interrupted/crashed mid-
|
| 343 |
+
retry can persist one of these as a plain role="user" row (their
|
| 344 |
+
_length_continuation_nudge/_dropped_toolcall_nudge metadata tags do not
|
| 345 |
+
survive SessionDB projection), so recognition must be content-based."""
|
| 346 |
+
nudge = {"role": "user", "content": content}
|
| 347 |
+
assert ContextCompressor._is_synthetic_compression_user_turn(nudge) is True
|
| 348 |
+
|
| 349 |
+
human = {"role": "user", "content": "Ship the release notes for v2."}
|
| 350 |
+
assert ContextCompressor._is_synthetic_compression_user_turn(human) is False
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def test_real_task_wins_over_trailing_dropped_tools_continuation_nudge(compressor):
|
| 354 |
+
"""The dropped-tools continuation nudge interpolates the tool name list,
|
| 355 |
+
so it can only be recognized by a stable prefix (unlike the other nudges,
|
| 356 |
+
which are exact-matched) — this proves that prefix path actually wires
|
| 357 |
+
into anchor selection, not just the classifier in isolation."""
|
| 358 |
+
human = {"role": "user", "content": "Refactor the auth module and add tests."}
|
| 359 |
+
messages = [
|
| 360 |
+
human,
|
| 361 |
+
{"role": "assistant", "content": "Working on it.", "tool_calls": [
|
| 362 |
+
{"id": "c1", "function": {"name": "write_file", "arguments": "{}"}}
|
| 363 |
+
]},
|
| 364 |
+
{"role": "tool", "tool_call_id": "c1", "content": "ok"},
|
| 365 |
+
{
|
| 366 |
+
"role": "user",
|
| 367 |
+
"content": (
|
| 368 |
+
"[System: Your previous tool call (write_file, patch_file) was "
|
| 369 |
+
"too large and the stream timed out before it could be "
|
| 370 |
+
"delivered. Do NOT retry the same tool call with the same "
|
| 371 |
+
"large content. Instead, break the content into multiple "
|
| 372 |
+
"smaller tool calls (e.g. use multiple patch calls or write "
|
| 373 |
+
"smaller files). Each tool call's arguments must be under "
|
| 374 |
+
"~8K tokens to avoid stream timeouts.]"
|
| 375 |
+
),
|
| 376 |
+
},
|
| 377 |
+
]
|
| 378 |
+
|
| 379 |
+
idx = compressor._find_last_user_message_idx(messages, head_end=0)
|
| 380 |
+
assert idx == 0, "nudge was selected as the anchor instead of the human task"
|
| 381 |
+
assert messages[idx]["content"] == human["content"]
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def test_compress_context_todo_snapshot_stays_synthetic_across_two_boundaries(
|
| 385 |
+
tmp_path, monkeypatch
|
| 386 |
+
):
|
| 387 |
+
hermes_home = tmp_path / "hermes-home"
|
| 388 |
+
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
| 389 |
+
db = SessionDB(db_path=tmp_path / "state.db")
|
| 390 |
+
session_id = "zero-user-todo-lifecycle"
|
| 391 |
+
db.create_session(session_id, source="cron", model="test/model")
|
| 392 |
+
|
| 393 |
+
first_agent = _lifecycle_agent(db, session_id)
|
| 394 |
+
with patch(
|
| 395 |
+
"agent.context_compressor.call_llm",
|
| 396 |
+
return_value=_response(_valid_zero_user_summary("First boundary")),
|
| 397 |
+
):
|
| 398 |
+
first, _ = compress_context(
|
| 399 |
+
first_agent,
|
| 400 |
+
_assistant_turns(0, 24),
|
| 401 |
+
"system",
|
| 402 |
+
approx_tokens=90_000,
|
| 403 |
+
force=True,
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
first_handoff = next(
|
| 407 |
+
message
|
| 408 |
+
for message in first
|
| 409 |
+
if message.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
| 410 |
+
)
|
| 411 |
+
assert first_handoff[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False
|
| 412 |
+
assert "First boundary" in first_handoff["content"]
|
| 413 |
+
assert any(
|
| 414 |
+
message.get("role") == "user"
|
| 415 |
+
and str(message.get("content") or "").startswith(TODO_INJECTION_HEADER)
|
| 416 |
+
for message in first
|
| 417 |
+
)
|
| 418 |
+
projected = db.get_messages_as_conversation(session_id)
|
| 419 |
+
assert projected
|
| 420 |
+
assert all(
|
| 421 |
+
COMPRESSED_SUMMARY_METADATA_KEY not in message
|
| 422 |
+
and COMPRESSED_SUMMARY_HAS_USER_TURN_KEY not in message
|
| 423 |
+
for message in projected
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
second_agent = _lifecycle_agent(db, session_id)
|
| 427 |
+
with patch(
|
| 428 |
+
"agent.context_compressor.call_llm",
|
| 429 |
+
return_value=_response(_valid_zero_user_summary("Second boundary")),
|
| 430 |
+
):
|
| 431 |
+
second, _ = compress_context(
|
| 432 |
+
second_agent,
|
| 433 |
+
[*projected, *_assistant_turns(30, 24)],
|
| 434 |
+
"system",
|
| 435 |
+
approx_tokens=90_000,
|
| 436 |
+
force=True,
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
handoff = next(
|
| 440 |
+
message
|
| 441 |
+
for message in second
|
| 442 |
+
if message.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
| 443 |
+
)
|
| 444 |
+
assert handoff[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False
|
| 445 |
+
assert "Second boundary" in handoff["content"]
|
| 446 |
+
assert "User asked:" not in handoff["content"]
|
| 447 |
+
db.close()
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
|
tests/agent/test_context_refs_concurrent.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for concurrent @-reference expansion in context_references.
|
| 2 |
+
|
| 3 |
+
test_refs_expand_concurrently asserts that N URL refs are fetched CONCURRENTLY.
|
| 4 |
+
It proves this with an asyncio.Barrier rendezvous rather than a stopwatch: all
|
| 5 |
+
N fetches must be in flight at the same instant before any is allowed to
|
| 6 |
+
return. On the serial `for ref in refs: await` loop the first fetch waits for
|
| 7 |
+
partners that never arrive and the test fails; with asyncio.gather it passes.
|
| 8 |
+
The output-contract test guards that concurrency does NOT change ordering,
|
| 9 |
+
warnings, blocks, or token accounting.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from agent.context_references import preprocess_context_references_async
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def _slow_fetcher(url: str) -> str:
|
| 21 |
+
# Simulate a per-URL network fetch (web_extract round trip).
|
| 22 |
+
await asyncio.sleep(0.2)
|
| 23 |
+
return f"CONTENT[{url}]"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.mark.asyncio
|
| 27 |
+
async def test_refs_expand_concurrently(tmp_path):
|
| 28 |
+
# Three independent URL refs in one message.
|
| 29 |
+
msg = "see @url:https://a.example/x @url:https://b.example/y @url:https://c.example/z please"
|
| 30 |
+
|
| 31 |
+
# Concurrency is proven by construction, not by measuring elapsed time.
|
| 32 |
+
#
|
| 33 |
+
# The old form asserted `elapsed < 0.4` ("well under 2x one 0.2s fetch").
|
| 34 |
+
# That makes the event-loop scheduler and any fixed setup part of the
|
| 35 |
+
# assertion: under a loaded CI box the inequality can flip with nothing
|
| 36 |
+
# wrong in the code under test, and the margin shrinks silently if setup
|
| 37 |
+
# cost is ever added ahead of dispatch.
|
| 38 |
+
#
|
| 39 |
+
# A barrier asserts the invariant directly: all THREE fetches must be
|
| 40 |
+
# inside the fetcher AT THE SAME TIME before any is allowed to return. If
|
| 41 |
+
# expansion ever goes serial the first fetch blocks waiting for partners
|
| 42 |
+
# that will not arrive, the barrier times out, and the test fails with an
|
| 43 |
+
# explicit message. No wall-clock constant, no load sensitivity.
|
| 44 |
+
N_REFS = 3
|
| 45 |
+
rendezvous = asyncio.Barrier(N_REFS)
|
| 46 |
+
entered: list[str] = []
|
| 47 |
+
overlapped = asyncio.Event()
|
| 48 |
+
|
| 49 |
+
async def barrier_fetcher(url: str) -> str:
|
| 50 |
+
entered.append(url)
|
| 51 |
+
# Generous relative to real scheduling latency (a rendezvous between
|
| 52 |
+
# already-dispatched coroutines needs milliseconds), but finite so a
|
| 53 |
+
# serial regression fails fast instead of hanging the suite.
|
| 54 |
+
async with asyncio.timeout(10):
|
| 55 |
+
await rendezvous.wait()
|
| 56 |
+
overlapped.set()
|
| 57 |
+
return f"CONTENT[{url}]"
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
res = await preprocess_context_references_async(
|
| 61 |
+
msg, cwd=tmp_path, context_length=100_000, url_fetcher=barrier_fetcher,
|
| 62 |
+
)
|
| 63 |
+
except (asyncio.BrokenBarrierError, TimeoutError): # pragma: no cover - serial regression
|
| 64 |
+
pytest.fail(
|
| 65 |
+
"references did not expand concurrently: a fetch reached the "
|
| 66 |
+
f"rendezvous alone, so expansion never had {N_REFS} fetches in "
|
| 67 |
+
f"flight at once (entered: {entered})"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# The barrier only clears when all three fetches are in flight together.
|
| 71 |
+
assert overlapped.is_set(), f"references never overlapped (entered: {entered})"
|
| 72 |
+
assert len(entered) == N_REFS, f"expected {N_REFS} fetches, got {entered}"
|
| 73 |
+
# All three blocks present, in order.
|
| 74 |
+
assert res.expanded
|
| 75 |
+
body = res.message
|
| 76 |
+
assert body.index("a.example") < body.index("b.example") < body.index("c.example"), \
|
| 77 |
+
"reference blocks must stay in original order"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@pytest.mark.asyncio
|
| 81 |
+
async def test_concurrent_preserves_output_contract(tmp_path):
|
| 82 |
+
"""Concurrency must not change which blocks/warnings appear or their order."""
|
| 83 |
+
msg = "@url:https://one.example/p @url:https://two.example/q"
|
| 84 |
+
res = await preprocess_context_references_async(
|
| 85 |
+
msg, cwd=tmp_path, context_length=100_000, url_fetcher=_slow_fetcher,
|
| 86 |
+
)
|
| 87 |
+
assert "CONTENT[https://one.example/p]" in res.message
|
| 88 |
+
assert "CONTENT[https://two.example/q]" in res.message
|
| 89 |
+
assert res.message.index("one.example") < res.message.index("two.example")
|
| 90 |
+
assert res.injected_tokens > 0
|
tests/agent/test_conversation_loop_interpreter_shutdown.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression guard: interpreter-shutdown errors must abort the conversation
|
| 2 |
+
loop immediately instead of retrying.
|
| 3 |
+
|
| 4 |
+
When the Python interpreter begins its teardown sequence, every executor-backed
|
| 5 |
+
operation (API calls, tool dispatch, memory sync) raises::
|
| 6 |
+
|
| 7 |
+
RuntimeError: cannot schedule new futures after interpreter shutdown
|
| 8 |
+
|
| 9 |
+
Before the fix, the outer ``except Exception`` handler in
|
| 10 |
+
``run_conversation`` caught this error but did not recognise it as fatal.
|
| 11 |
+
Since ``api_call_count`` was nowhere near ``agent.max_iterations - 1``, the
|
| 12 |
+
loop continued — each iteration hit the same dead executor and failed
|
| 13 |
+
identically, producing a cascade of ``❌ Error during OpenAI-compatible API
|
| 14 |
+
call #N`` messages (#93217).
|
| 15 |
+
|
| 16 |
+
The fix adds an early check in the outer except handler: if
|
| 17 |
+
``sys.is_finalizing()`` is True or the error message matches the
|
| 18 |
+
``"cannot schedule new futures"`` pattern, the loop breaks immediately with
|
| 19 |
+
a clean ``interpreter_shutdown`` exit reason.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from agent.conversation_loop import _is_interpreter_shutdown_error
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TestInterpreterShutdownDetection:
|
| 27 |
+
"""Verify the interpreter-shutdown error matcher used by the
|
| 28 |
+
conversation loop's outer except handler."""
|
| 29 |
+
|
| 30 |
+
def test_matches_full_interpreter_shutdown_message(self):
|
| 31 |
+
"""The canonical CPython asyncio shutdown message."""
|
| 32 |
+
exc = RuntimeError(
|
| 33 |
+
"cannot schedule new futures after interpreter shutdown"
|
| 34 |
+
)
|
| 35 |
+
assert _is_interpreter_shutdown_error(exc) is True
|
| 36 |
+
|
| 37 |
+
def test_matches_short_shutdown_variant(self):
|
| 38 |
+
"""Plain ThreadPoolExecutor shutdown variant (no 'interpreter')."""
|
| 39 |
+
exc = RuntimeError("cannot schedule new futures after shutdown")
|
| 40 |
+
assert _is_interpreter_shutdown_error(exc) is True
|
| 41 |
+
|
| 42 |
+
def test_case_insensitive_match(self):
|
| 43 |
+
"""Error text may arrive in different case from some executor types."""
|
| 44 |
+
exc = RuntimeError("Cannot Schedule New Futures After Interpreter Shutdown")
|
| 45 |
+
assert _is_interpreter_shutdown_error(exc) is True
|
| 46 |
+
|
| 47 |
+
def test_does_not_match_unrelated_runtime_error(self):
|
| 48 |
+
"""Unrelated RuntimeErrors must not trigger the shutdown path."""
|
| 49 |
+
exc = RuntimeError("connection reset by peer")
|
| 50 |
+
assert _is_interpreter_shutdown_error(exc) is False
|
| 51 |
+
|
| 52 |
+
def test_does_not_match_non_runtime_error(self):
|
| 53 |
+
"""Non-RuntimeError exceptions must not match."""
|
| 54 |
+
exc = ValueError("cannot schedule new futures")
|
| 55 |
+
assert _is_interpreter_shutdown_error(exc) is False
|
| 56 |
+
|
| 57 |
+
def test_does_not_match_none(self):
|
| 58 |
+
"""None must not match (defensive — caller may pass None)."""
|
| 59 |
+
try:
|
| 60 |
+
result = _is_interpreter_shutdown_error(None) # type: ignore[arg-type]
|
| 61 |
+
except TypeError:
|
| 62 |
+
result = False
|
| 63 |
+
assert result is False
|
| 64 |
+
|
| 65 |
+
def test_does_not_match_empty_string_exception(self):
|
| 66 |
+
"""Empty-message exceptions must not match."""
|
| 67 |
+
exc = RuntimeError("")
|
| 68 |
+
assert _is_interpreter_shutdown_error(exc) is False
|
tests/agent/test_copilot_initiator.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for per-turn Copilot x-initiator header injection (issue #3040).
|
| 2 |
+
|
| 3 |
+
Copilot bills "premium requests" only when a request is marked as
|
| 4 |
+
user-initiated via the ``x-initiator: user`` header. Hermes previously sent
|
| 5 |
+
``x-initiator: agent`` on every request (client-level default headers), so
|
| 6 |
+
user prompts never consumed premium requests and were throttled as agent
|
| 7 |
+
traffic. The fix marks the FIRST API call of each user turn as "user" and
|
| 8 |
+
lets tool-loop follow-ups keep the "agent" default.
|
| 9 |
+
|
| 10 |
+
Salvaged from PR #4097 (@tjp2021); adapted to the post-refactor layout
|
| 11 |
+
(conversation_loop.py owns the injection site, the codex transport now
|
| 12 |
+
accepts extra_headers).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from run_agent import AIAgent
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _tool_defs(*names):
|
| 21 |
+
return [
|
| 22 |
+
{"type": "function", "function": {"name": n, "description": n, "parameters": {}}}
|
| 23 |
+
for n in names
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class _FakeOpenAI:
|
| 28 |
+
def __init__(self, **kw):
|
| 29 |
+
self.api_key = kw.get("api_key", "test")
|
| 30 |
+
self.base_url = kw.get("base_url", "http://test")
|
| 31 |
+
|
| 32 |
+
def close(self):
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _make_agent(monkeypatch, base_url, api_mode="chat_completions"):
|
| 37 |
+
"""Create an AIAgent pointing at the given base_url."""
|
| 38 |
+
monkeypatch.setattr("model_tools.get_tool_definitions", lambda **kw: _tool_defs("web_search"))
|
| 39 |
+
monkeypatch.setattr("model_tools.check_toolset_requirements", lambda: {})
|
| 40 |
+
monkeypatch.setattr("agent.process_bootstrap.OpenAI", _FakeOpenAI)
|
| 41 |
+
return AIAgent(
|
| 42 |
+
api_key="test-key",
|
| 43 |
+
base_url=base_url,
|
| 44 |
+
provider="copilot" if "githubcopilot" in base_url else "openrouter",
|
| 45 |
+
api_mode=api_mode,
|
| 46 |
+
max_iterations=4,
|
| 47 |
+
quiet_mode=True,
|
| 48 |
+
skip_context_files=True,
|
| 49 |
+
skip_memory=True,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _inject(agent, api_kwargs):
|
| 54 |
+
"""Mirror the injection block in agent/conversation_loop.py."""
|
| 55 |
+
if getattr(agent, "_is_user_initiated_turn", False) and agent._is_copilot_url():
|
| 56 |
+
_xh = dict(api_kwargs.get("extra_headers") or {})
|
| 57 |
+
_xh["x-initiator"] = "user"
|
| 58 |
+
api_kwargs["extra_headers"] = _xh
|
| 59 |
+
agent._is_user_initiated_turn = False
|
| 60 |
+
return api_kwargs
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TestIsCopilotUrl:
|
| 64 |
+
"""_is_copilot_url() detects GitHub Copilot endpoints."""
|
| 65 |
+
|
| 66 |
+
def test_standard_copilot_url(self, monkeypatch):
|
| 67 |
+
agent = _make_agent(monkeypatch, "https://api.githubcopilot.com")
|
| 68 |
+
assert agent._is_copilot_url() is True
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_github_models_url(self, monkeypatch):
|
| 72 |
+
agent = _make_agent(monkeypatch, "https://models.github.ai/inference")
|
| 73 |
+
assert agent._is_copilot_url() is True
|
| 74 |
+
|
| 75 |
+
def test_openrouter_url(self, monkeypatch):
|
| 76 |
+
agent = _make_agent(monkeypatch, "https://openrouter.ai/api/v1")
|
| 77 |
+
assert agent._is_copilot_url() is False
|
| 78 |
+
|
| 79 |
+
def test_case_insensitive(self, monkeypatch):
|
| 80 |
+
agent = _make_agent(monkeypatch, "https://API.GITHUBCOPILOT.COM")
|
| 81 |
+
assert agent._is_copilot_url() is True
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class TestUserInitiatedTurnFlag:
|
| 85 |
+
"""_is_user_initiated_turn lifecycle."""
|
| 86 |
+
|
| 87 |
+
def test_default_is_false(self, monkeypatch):
|
| 88 |
+
agent = _make_agent(monkeypatch, "https://api.githubcopilot.com")
|
| 89 |
+
assert agent._is_user_initiated_turn is False
|
| 90 |
+
|
| 91 |
+
def test_reset_session_clears_flag(self, monkeypatch):
|
| 92 |
+
agent = _make_agent(monkeypatch, "https://api.githubcopilot.com")
|
| 93 |
+
agent._is_user_initiated_turn = True
|
| 94 |
+
agent.reset_session_state()
|
| 95 |
+
assert agent._is_user_initiated_turn is False
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class TestFlagFlipOnInjection:
|
| 99 |
+
"""Flag flips immediately on injection so tool-loop calls use 'agent'."""
|
| 100 |
+
|
| 101 |
+
def test_first_call_injects_user_initiator(self, monkeypatch):
|
| 102 |
+
agent = _make_agent(monkeypatch, "https://api.githubcopilot.com")
|
| 103 |
+
agent._is_user_initiated_turn = True
|
| 104 |
+
kwargs = _inject(agent, {})
|
| 105 |
+
assert kwargs["extra_headers"] == {"x-initiator": "user"}
|
| 106 |
+
assert agent._is_user_initiated_turn is False
|
| 107 |
+
|
| 108 |
+
def test_second_call_has_no_injection(self, monkeypatch):
|
| 109 |
+
agent = _make_agent(monkeypatch, "https://api.githubcopilot.com")
|
| 110 |
+
agent._is_user_initiated_turn = True
|
| 111 |
+
kwargs1 = _inject(agent, {})
|
| 112 |
+
kwargs2 = _inject(agent, {})
|
| 113 |
+
assert "extra_headers" in kwargs1
|
| 114 |
+
assert "extra_headers" not in kwargs2
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_non_copilot_flag_not_flipped(self, monkeypatch):
|
| 118 |
+
agent = _make_agent(monkeypatch, "https://openrouter.ai/api/v1")
|
| 119 |
+
agent._is_user_initiated_turn = True
|
| 120 |
+
kwargs = _inject(agent, {})
|
| 121 |
+
assert "extra_headers" not in kwargs
|
| 122 |
+
# Flag unchanged — non-Copilot path doesn't touch it
|
| 123 |
+
assert agent._is_user_initiated_turn is True
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class TestHeaderValues:
|
| 127 |
+
"""copilot_default_headers(is_agent_turn=...) sets x-initiator correctly."""
|
| 128 |
+
|
| 129 |
+
def test_default_is_agent(self):
|
| 130 |
+
from hermes_cli.models import copilot_default_headers
|
| 131 |
+
assert copilot_default_headers()["x-initiator"] == "agent"
|
| 132 |
+
|
| 133 |
+
def test_user_turn(self):
|
| 134 |
+
from hermes_cli.models import copilot_default_headers
|
| 135 |
+
assert copilot_default_headers(is_agent_turn=False)["x-initiator"] == "user"
|
| 136 |
+
|
| 137 |
+
def test_agent_turn_explicit(self):
|
| 138 |
+
from hermes_cli.models import copilot_default_headers
|
| 139 |
+
assert copilot_default_headers(is_agent_turn=True)["x-initiator"] == "agent"
|
tests/agent/test_create_openai_client_disables_sdk_retries.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression guard: _create_openai_client must disable SDK-level retries.
|
| 2 |
+
|
| 3 |
+
#26293: the OpenAI SDK default ``max_retries=2`` uses its own 1-2s backoff that
|
| 4 |
+
ignores ``Retry-After`` and double-retries *inside* hermes's outer conversation
|
| 5 |
+
loop — burning request slots against a rate-limited bucket that won't refill for
|
| 6 |
+
minutes. The outer loop already owns rate-limit backoff (honors Retry-After,
|
| 7 |
+
adaptive + jittered), so every primary OpenAI/aggregator client must be built
|
| 8 |
+
with ``max_retries=0``. This is the OpenAI-path twin of the Anthropic adapter
|
| 9 |
+
fix in tests/agent/test_anthropic_adapter.py.
|
| 10 |
+
"""
|
| 11 |
+
from unittest.mock import MagicMock, patch
|
| 12 |
+
|
| 13 |
+
from run_agent import AIAgent
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@patch("agent.process_bootstrap.OpenAI")
|
| 17 |
+
def test_create_openai_client_disables_sdk_retries(mock_openai):
|
| 18 |
+
mock_openai.return_value = MagicMock()
|
| 19 |
+
agent = AIAgent(
|
| 20 |
+
api_key="test-key",
|
| 21 |
+
base_url="https://openrouter.ai/api/v1",
|
| 22 |
+
model="test/model",
|
| 23 |
+
quiet_mode=True,
|
| 24 |
+
skip_context_files=True,
|
| 25 |
+
skip_memory=True,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
agent._create_openai_client(
|
| 29 |
+
{"api_key": "test-key", "base_url": "https://openrouter.ai/api/v1"},
|
| 30 |
+
reason="test",
|
| 31 |
+
shared=False,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Find the construction call that carries our base_url (AIAgent() also
|
| 35 |
+
# builds a client during init; the assertion targets the explicit call).
|
| 36 |
+
matching = [
|
| 37 |
+
c for c in mock_openai.call_args_list
|
| 38 |
+
if c.kwargs.get("base_url") == "https://openrouter.ai/api/v1"
|
| 39 |
+
]
|
| 40 |
+
assert matching, "OpenAI was never constructed with the expected base_url"
|
| 41 |
+
for call in matching:
|
| 42 |
+
assert call.kwargs.get("max_retries") == 0, (
|
| 43 |
+
"_create_openai_client must set max_retries=0 so the SDK does not "
|
| 44 |
+
"double-retry inside the outer rate-limit loop (#26293); got "
|
| 45 |
+
f"{call.kwargs.get('max_retries')!r}"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@patch("agent.process_bootstrap.OpenAI")
|
| 50 |
+
def test_create_openai_client_honors_explicit_max_retries(mock_openai):
|
| 51 |
+
"""An explicit max_retries in client_kwargs is respected (setdefault, not
|
| 52 |
+
clobber) — future callers can opt back into SDK retries if needed."""
|
| 53 |
+
mock_openai.return_value = MagicMock()
|
| 54 |
+
agent = AIAgent(
|
| 55 |
+
api_key="test-key",
|
| 56 |
+
base_url="https://openrouter.ai/api/v1",
|
| 57 |
+
model="test/model",
|
| 58 |
+
quiet_mode=True,
|
| 59 |
+
skip_context_files=True,
|
| 60 |
+
skip_memory=True,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
agent._create_openai_client(
|
| 64 |
+
{
|
| 65 |
+
"api_key": "test-key",
|
| 66 |
+
"base_url": "https://explicit.example.com/v1",
|
| 67 |
+
"max_retries": 5,
|
| 68 |
+
},
|
| 69 |
+
reason="test",
|
| 70 |
+
shared=False,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
matching = [
|
| 74 |
+
c for c in mock_openai.call_args_list
|
| 75 |
+
if c.kwargs.get("base_url") == "https://explicit.example.com/v1"
|
| 76 |
+
]
|
| 77 |
+
assert matching, "OpenAI was never constructed with the explicit base_url"
|
| 78 |
+
assert matching[-1].kwargs.get("max_retries") == 5
|
tests/agent/test_create_openai_client_reuse.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression guardrail: sequential _create_openai_client calls must not
|
| 2 |
+
share a closed transport across invocations.
|
| 3 |
+
|
| 4 |
+
This is the behavioral twin of test_create_openai_client_kwargs_isolation.py.
|
| 5 |
+
That test pins "don't mutate input kwargs" at the syntactic level — it catches
|
| 6 |
+
#10933 specifically because the bug mutated ``client_kwargs`` in place. This
|
| 7 |
+
test pins the user-visible invariant at the behavioral level: no matter HOW a
|
| 8 |
+
future keepalive / transport reimplementation plumbs sockets in, the Nth call
|
| 9 |
+
to ``_create_openai_client`` must not hand back a client wrapping a
|
| 10 |
+
now-closed httpx transport from an earlier call.
|
| 11 |
+
|
| 12 |
+
AlexKucera's Discord report (2026-04-16): after ``hermes update`` pulled
|
| 13 |
+
#10933, the first chat on a session worked, every subsequent chat failed
|
| 14 |
+
with ``APIConnectionError('Connection error.')`` whose cause was
|
| 15 |
+
``RuntimeError: Cannot send a request, as the client has been closed``.
|
| 16 |
+
That is the exact scenario this test reproduces at object level without a
|
| 17 |
+
network, so it runs in CI on every PR.
|
| 18 |
+
"""
|
| 19 |
+
from types import SimpleNamespace
|
| 20 |
+
from unittest.mock import patch
|
| 21 |
+
|
| 22 |
+
from run_agent import AIAgent
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _make_agent():
|
| 26 |
+
return AIAgent(
|
| 27 |
+
api_key="test-key",
|
| 28 |
+
base_url="https://openrouter.ai/api/v1",
|
| 29 |
+
model="test/model",
|
| 30 |
+
quiet_mode=True,
|
| 31 |
+
skip_context_files=True,
|
| 32 |
+
skip_memory=True,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _make_fake_openai_factory(constructed):
|
| 37 |
+
"""Return a fake ``OpenAI`` class that records every constructed instance
|
| 38 |
+
along with whatever ``http_client`` it was handed (or ``None`` if the
|
| 39 |
+
caller did not inject one).
|
| 40 |
+
|
| 41 |
+
The fake also forwards ``.close()`` calls down to the http_client if one
|
| 42 |
+
is present, mirroring what the real OpenAI SDK does during teardown and
|
| 43 |
+
what would expose the #10933 bug.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
class _FakeOpenAI:
|
| 47 |
+
def __init__(self, **kwargs):
|
| 48 |
+
self._kwargs = kwargs
|
| 49 |
+
self._http_client = kwargs.get("http_client")
|
| 50 |
+
self._closed = False
|
| 51 |
+
constructed.append(self)
|
| 52 |
+
|
| 53 |
+
def close(self):
|
| 54 |
+
self._closed = True
|
| 55 |
+
hc = self._http_client
|
| 56 |
+
if hc is not None and hasattr(hc, "close"):
|
| 57 |
+
try:
|
| 58 |
+
hc.close()
|
| 59 |
+
except Exception:
|
| 60 |
+
pass
|
| 61 |
+
|
| 62 |
+
return _FakeOpenAI
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_second_create_does_not_wrap_closed_transport_from_first():
|
| 66 |
+
"""Back-to-back _create_openai_client calls on the same _client_kwargs
|
| 67 |
+
must not hand call N a closed http_client from call N-1.
|
| 68 |
+
|
| 69 |
+
The bug class: call 1 injects an httpx.Client into self._client_kwargs,
|
| 70 |
+
client 1 closes (SDK teardown), its http_client closes with it, call 2
|
| 71 |
+
reads the SAME now-closed http_client from self._client_kwargs and wraps
|
| 72 |
+
it. Every request through client 2 then fails.
|
| 73 |
+
"""
|
| 74 |
+
agent = _make_agent()
|
| 75 |
+
constructed: list = []
|
| 76 |
+
fake_openai = _make_fake_openai_factory(constructed)
|
| 77 |
+
|
| 78 |
+
# Seed a baseline kwargs dict resembling real runtime state.
|
| 79 |
+
agent._client_kwargs = {
|
| 80 |
+
"api_key": "test-key-value",
|
| 81 |
+
"base_url": "https://api.example.com/v1",
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
with patch("agent.process_bootstrap.OpenAI", fake_openai):
|
| 85 |
+
# Call 1 — what _replace_primary_openai_client does at init/rebuild.
|
| 86 |
+
client_a = agent._create_openai_client(
|
| 87 |
+
agent._client_kwargs, reason="initial", shared=True
|
| 88 |
+
)
|
| 89 |
+
# Simulate the SDK teardown that follows a rebuild: the old client's
|
| 90 |
+
# close() is invoked, which closes its underlying http_client if one
|
| 91 |
+
# was injected. This is exactly what _replace_primary_openai_client
|
| 92 |
+
# does via _close_openai_client after a successful rebuild.
|
| 93 |
+
client_a.close()
|
| 94 |
+
|
| 95 |
+
# Call 2 — the rebuild path. This is where #10933 crashed on the
|
| 96 |
+
# next real request.
|
| 97 |
+
client_b = agent._create_openai_client(
|
| 98 |
+
agent._client_kwargs, reason="rebuild", shared=True
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
assert len(constructed) == 2, f"expected 2 OpenAI constructions, got {len(constructed)}"
|
| 102 |
+
assert constructed[0] is client_a
|
| 103 |
+
assert constructed[1] is client_b
|
| 104 |
+
|
| 105 |
+
hc_a = constructed[0]._http_client
|
| 106 |
+
hc_b = constructed[1]._http_client
|
| 107 |
+
|
| 108 |
+
# If the implementation does not inject http_client at all, we're safely
|
| 109 |
+
# past the bug class — nothing to share, nothing to close. That's fine.
|
| 110 |
+
if hc_a is None and hc_b is None:
|
| 111 |
+
return
|
| 112 |
+
|
| 113 |
+
# If ANY http_client is injected, the two calls MUST NOT share the same
|
| 114 |
+
# object, because call 1's object was closed between calls.
|
| 115 |
+
if hc_a is not None and hc_b is not None:
|
| 116 |
+
assert hc_a is not hc_b, (
|
| 117 |
+
"Regression of #10933: _create_openai_client handed the same "
|
| 118 |
+
"http_client to two sequential constructions. After the first "
|
| 119 |
+
"client is closed (normal SDK teardown on rebuild), the second "
|
| 120 |
+
"wraps a closed transport and every subsequent chat raises "
|
| 121 |
+
"'Cannot send a request, as the client has been closed'."
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# And whatever http_client the LATEST call handed out must not be closed
|
| 125 |
+
# already. This catches implementations that cache the injected client on
|
| 126 |
+
# ``self`` (under any attribute name) and rebuild the SDK client around
|
| 127 |
+
# it even after the previous SDK close closed the cached transport.
|
| 128 |
+
if hc_b is not None:
|
| 129 |
+
is_closed_attr = getattr(hc_b, "is_closed", None)
|
| 130 |
+
if is_closed_attr is not None:
|
| 131 |
+
assert not is_closed_attr, (
|
| 132 |
+
"Regression of #10933: second _create_openai_client returned "
|
| 133 |
+
"a client whose http_client is already closed. New chats on "
|
| 134 |
+
"this session will fail with 'Cannot send a request, as the "
|
| 135 |
+
"client has been closed'."
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_replace_primary_openai_client_survives_repeated_rebuilds():
|
| 140 |
+
"""Full rebuild path: exercise _replace_primary_openai_client three times
|
| 141 |
+
back-to-back and confirm every resulting ``self.client`` is a fresh,
|
| 142 |
+
usable construction rather than a wrapper around a previously-closed
|
| 143 |
+
transport.
|
| 144 |
+
|
| 145 |
+
_replace_primary_openai_client is the real rebuild entrypoint — it is
|
| 146 |
+
what runs on 401 credential refresh, pool rotation, and model switch.
|
| 147 |
+
If a future keepalive tweak stores state on ``self`` between calls,
|
| 148 |
+
this test is what notices.
|
| 149 |
+
"""
|
| 150 |
+
agent = _make_agent()
|
| 151 |
+
constructed: list = []
|
| 152 |
+
fake_openai = _make_fake_openai_factory(constructed)
|
| 153 |
+
|
| 154 |
+
agent._client_kwargs = {
|
| 155 |
+
"api_key": "test-key-value",
|
| 156 |
+
"base_url": "https://api.example.com/v1",
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
with patch("agent.process_bootstrap.OpenAI", fake_openai):
|
| 160 |
+
# Seed the initial client so _replace has something to tear down.
|
| 161 |
+
agent.client = agent._create_openai_client(
|
| 162 |
+
agent._client_kwargs, reason="seed", shared=True
|
| 163 |
+
)
|
| 164 |
+
# Three rebuilds in a row. Each one must install a fresh live client.
|
| 165 |
+
for label in ("rebuild_1", "rebuild_2", "rebuild_3"):
|
| 166 |
+
ok = agent._replace_primary_openai_client(reason=label)
|
| 167 |
+
assert ok, f"rebuild {label} returned False"
|
| 168 |
+
cur = agent.client
|
| 169 |
+
assert not cur._closed, (
|
| 170 |
+
f"after rebuild {label}, self.client is already closed — "
|
| 171 |
+
"this breaks the very next chat turn"
|
| 172 |
+
)
|
| 173 |
+
hc = cur._http_client
|
| 174 |
+
if hc is not None:
|
| 175 |
+
is_closed_attr = getattr(hc, "is_closed", None)
|
| 176 |
+
if is_closed_attr is not None:
|
| 177 |
+
assert not is_closed_attr, (
|
| 178 |
+
f"after rebuild {label}, self.client.http_client is "
|
| 179 |
+
"closed — reproduces #10933 (AlexKucera report, "
|
| 180 |
+
"Discord 2026-04-16)"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# All four constructions (seed + 3 rebuilds) should be distinct objects.
|
| 184 |
+
# If two are the same, the rebuild is cacheing the SDK client across
|
| 185 |
+
# teardown, which also reproduces the bug class.
|
| 186 |
+
assert len({id(c) for c in constructed}) == len(constructed), (
|
| 187 |
+
"Some _create_openai_client calls returned the same object across "
|
| 188 |
+
"a teardown — rebuild is not producing fresh clients"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_force_close_tcp_sockets_descends_httpcore_1_connection_wrapper():
|
| 193 |
+
"""httpcore 1.x stores the real stream below conn._connection.
|
| 194 |
+
|
| 195 |
+
Post-#29507: the helper must shut sockets down but must NOT release the
|
| 196 |
+
FD via ``sock.close()`` — that race recycled FDs into unrelated file
|
| 197 |
+
descriptors (kanban.db) and let TLS bytes overwrite SQLite headers. The
|
| 198 |
+
owning httpx thread is responsible for closing FDs on its own unwind.
|
| 199 |
+
"""
|
| 200 |
+
from agent.agent_runtime_helpers import force_close_tcp_sockets
|
| 201 |
+
|
| 202 |
+
class FakeSocket:
|
| 203 |
+
def __init__(self):
|
| 204 |
+
self.shutdown_calls = 0
|
| 205 |
+
self.close_calls = 0
|
| 206 |
+
|
| 207 |
+
def shutdown(self, _how):
|
| 208 |
+
self.shutdown_calls += 1
|
| 209 |
+
|
| 210 |
+
def close(self):
|
| 211 |
+
self.close_calls += 1
|
| 212 |
+
|
| 213 |
+
sock = FakeSocket()
|
| 214 |
+
stream = SimpleNamespace(_sock=sock)
|
| 215 |
+
http11 = SimpleNamespace(_network_stream=stream)
|
| 216 |
+
pool_entry = SimpleNamespace(_connection=http11)
|
| 217 |
+
pool = SimpleNamespace(_connections=[pool_entry])
|
| 218 |
+
transport = SimpleNamespace(_pool=pool)
|
| 219 |
+
http_client = SimpleNamespace(_transport=transport)
|
| 220 |
+
openai_client = SimpleNamespace(_client=http_client)
|
| 221 |
+
|
| 222 |
+
assert force_close_tcp_sockets(openai_client) == 1
|
| 223 |
+
assert sock.shutdown_calls == 1
|
| 224 |
+
# #29507: close() must NOT be called from this helper — the owning
|
| 225 |
+
# httpx worker thread releases the FD, not us.
|
| 226 |
+
assert sock.close_calls == 0
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_force_close_tcp_sockets_finds_sockets_on_httpx_mounts():
|
| 230 |
+
"""HTTP(S)_PROXY / keepalive mounts put live sockets on ``_mounts``.
|
| 231 |
+
|
| 232 |
+
#72975: walking only ``_transport`` returned tcp_force_closed=0 while the
|
| 233 |
+
stream was still mid-recv on a mounted proxy pool, so interrupt logged
|
| 234 |
+
success and the provider kept the slot for minutes.
|
| 235 |
+
"""
|
| 236 |
+
from agent.agent_runtime_helpers import force_close_tcp_sockets
|
| 237 |
+
|
| 238 |
+
class FakeSocket:
|
| 239 |
+
def __init__(self):
|
| 240 |
+
self.shutdown_calls = 0
|
| 241 |
+
self.close_calls = 0
|
| 242 |
+
|
| 243 |
+
def shutdown(self, _how):
|
| 244 |
+
self.shutdown_calls += 1
|
| 245 |
+
|
| 246 |
+
def close(self):
|
| 247 |
+
self.close_calls += 1
|
| 248 |
+
|
| 249 |
+
sock = FakeSocket()
|
| 250 |
+
stream = SimpleNamespace(_sock=sock)
|
| 251 |
+
# TunnelHTTPConnection-shaped: outer proxy wrapper → HTTP11 stream.
|
| 252 |
+
http11 = SimpleNamespace(_network_stream=stream)
|
| 253 |
+
tunnel = SimpleNamespace(_connection=http11)
|
| 254 |
+
mount_pool = SimpleNamespace(_connections=[tunnel])
|
| 255 |
+
mount_transport = SimpleNamespace(_pool=mount_pool)
|
| 256 |
+
# Default transport is empty — the failing layout from #72975.
|
| 257 |
+
empty_pool = SimpleNamespace(_connections=[])
|
| 258 |
+
default_transport = SimpleNamespace(_pool=empty_pool)
|
| 259 |
+
http_client = SimpleNamespace(
|
| 260 |
+
_transport=default_transport,
|
| 261 |
+
_mounts={"https://": mount_transport},
|
| 262 |
+
)
|
| 263 |
+
openai_client = SimpleNamespace(_client=http_client)
|
| 264 |
+
|
| 265 |
+
assert force_close_tcp_sockets(openai_client) == 1
|
| 266 |
+
assert sock.shutdown_calls == 1
|
| 267 |
+
assert sock.close_calls == 0
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def test_force_close_tcp_sockets_finds_in_flight_pool_request_sockets():
|
| 271 |
+
"""httpcore keeps the live connection on PoolRequest.connection.
|
| 272 |
+
|
| 273 |
+
#85252: walking only ``_connections`` (and treating an empty list as
|
| 274 |
+
falsy) returned tcp_force_closed=0 while the hung recv was still on
|
| 275 |
+
the in-flight request. Must shut that socket down without close().
|
| 276 |
+
"""
|
| 277 |
+
from agent.agent_runtime_helpers import force_close_tcp_sockets
|
| 278 |
+
|
| 279 |
+
class FakeSocket:
|
| 280 |
+
def __init__(self):
|
| 281 |
+
self.shutdown_calls = 0
|
| 282 |
+
self.close_calls = 0
|
| 283 |
+
self.timeouts = []
|
| 284 |
+
|
| 285 |
+
def settimeout(self, value):
|
| 286 |
+
self.timeouts.append(value)
|
| 287 |
+
|
| 288 |
+
def shutdown(self, _how):
|
| 289 |
+
self.shutdown_calls += 1
|
| 290 |
+
|
| 291 |
+
def close(self):
|
| 292 |
+
self.close_calls += 1
|
| 293 |
+
|
| 294 |
+
sock = FakeSocket()
|
| 295 |
+
stream = SimpleNamespace(_sock=sock)
|
| 296 |
+
http11 = SimpleNamespace(_network_stream=stream)
|
| 297 |
+
in_flight = SimpleNamespace(_connection=http11)
|
| 298 |
+
pool_req = SimpleNamespace(connection=in_flight)
|
| 299 |
+
# Empty _connections is the failing layout: the live socket lives
|
| 300 |
+
# only on the in-flight PoolRequest.
|
| 301 |
+
pool = SimpleNamespace(_connections=[], _requests=[pool_req])
|
| 302 |
+
transport = SimpleNamespace(_pool=pool)
|
| 303 |
+
http_client = SimpleNamespace(_transport=transport)
|
| 304 |
+
openai_client = SimpleNamespace(_client=http_client)
|
| 305 |
+
|
| 306 |
+
assert force_close_tcp_sockets(openai_client) == 1
|
| 307 |
+
assert sock.shutdown_calls == 1
|
| 308 |
+
assert sock.close_calls == 0
|
| 309 |
+
assert sock.timeouts == [0]
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def test_force_close_tcp_sockets_clears_timeout_before_shutdown():
|
| 313 |
+
"""Hung SSL recv with timeout=None can ignore SHUT_RDWR until the
|
| 314 |
+
socket timeout is cleared (#85252). Still no close() (#29507)."""
|
| 315 |
+
from agent.agent_runtime_helpers import force_close_tcp_sockets
|
| 316 |
+
|
| 317 |
+
class FakeSocket:
|
| 318 |
+
def __init__(self):
|
| 319 |
+
self.order = []
|
| 320 |
+
|
| 321 |
+
def settimeout(self, value):
|
| 322 |
+
self.order.append(("settimeout", value))
|
| 323 |
+
|
| 324 |
+
def shutdown(self, _how):
|
| 325 |
+
self.order.append(("shutdown", _how))
|
| 326 |
+
|
| 327 |
+
def close(self):
|
| 328 |
+
self.order.append(("close", None))
|
| 329 |
+
|
| 330 |
+
sock = FakeSocket()
|
| 331 |
+
stream = SimpleNamespace(_sock=sock)
|
| 332 |
+
http11 = SimpleNamespace(_network_stream=stream)
|
| 333 |
+
pool_entry = SimpleNamespace(_connection=http11)
|
| 334 |
+
pool = SimpleNamespace(_connections=[pool_entry])
|
| 335 |
+
transport = SimpleNamespace(_pool=pool)
|
| 336 |
+
http_client = SimpleNamespace(_transport=transport)
|
| 337 |
+
openai_client = SimpleNamespace(_client=http_client)
|
| 338 |
+
|
| 339 |
+
import socket as _socket
|
| 340 |
+
|
| 341 |
+
assert force_close_tcp_sockets(openai_client) == 1
|
| 342 |
+
assert sock.order == [
|
| 343 |
+
("settimeout", 0),
|
| 344 |
+
("shutdown", _socket.SHUT_RDWR),
|
| 345 |
+
]
|
| 346 |
+
|
| 347 |
+
|