SaylorTwift HF Staff commited on
Commit
7acbd80
·
verified ·
1 Parent(s): b729ea0

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. tests/agent/conftest.py +39 -0
  2. tests/agent/test_account_usage.py +229 -0
  3. tests/agent/test_anthropic_borrowed_row_authority.py +281 -0
  4. tests/agent/test_anthropic_client_auth.py +79 -0
  5. tests/agent/test_anthropic_oauth_stress.py +387 -0
  6. tests/agent/test_anthropic_output_field_leak.py +76 -0
  7. tests/agent/test_anthropic_truncation_continuation.py +97 -0
  8. tests/agent/test_api_content_row_addressed_backfill.py +238 -0
  9. tests/agent/test_arcee_trinity_overrides.py +189 -0
  10. tests/agent/test_aux_affordable_402_retry.py +197 -0
  11. tests/agent/test_auxiliary_anthropic_pool_fallback_regression.py +85 -0
  12. tests/agent/test_auxiliary_client_azure_foundry.py +325 -0
  13. tests/agent/test_auxiliary_concurrency.py +403 -0
  14. tests/agent/test_auxiliary_named_custom_providers.py +480 -0
  15. tests/agent/test_auxiliary_transient_retry.py +62 -0
  16. tests/agent/test_azure_identity_adapter.py +496 -0
  17. tests/agent/test_background_review_input_budget.py +239 -0
  18. tests/agent/test_background_review_summary.py +112 -0
  19. tests/agent/test_background_review_tool_call_guard.py +125 -0
  20. tests/agent/test_bedrock_empty_text_blocks.py +78 -0
  21. tests/agent/test_billing_unverified_carrythrough.py +171 -0
  22. tests/agent/test_billing_view.py +346 -0
  23. tests/agent/test_bitwarden_secrets.py +523 -0
  24. tests/agent/test_bounded_response.py +113 -0
  25. tests/agent/test_budget_reasoning_details_exclusion.py +125 -0
  26. tests/agent/test_builtin_memory_disabled_surface.py +315 -0
  27. tests/agent/test_cache_disabled_on_stubs.py +382 -0
  28. tests/agent/test_chat_completion_helpers_provider_sort.py +13 -0
  29. tests/agent/test_codex_app_server_event_bridge.py +401 -0
  30. tests/agent/test_codex_app_server_lifecycle.py +82 -0
  31. tests/agent/test_codex_echo_ownership.py +50 -0
  32. tests/agent/test_codex_no_tools_nonetype.py +132 -0
  33. tests/agent/test_command_secret_source.py +223 -0
  34. tests/agent/test_compaction_ops_notification_anchor.py +103 -0
  35. tests/agent/test_compaction_redaction_boundaries.py +223 -0
  36. tests/agent/test_compression_attempt_lifecycle.py +360 -0
  37. tests/agent/test_compression_boundary.py +145 -0
  38. tests/agent/test_compression_concurrent_fork.py +2403 -0
  39. tests/agent/test_compression_interrupt_protection.py +175 -0
  40. tests/agent/test_compression_max_attempts_config.py +93 -0
  41. tests/agent/test_compression_split_failure_cooldown.py +56 -0
  42. tests/agent/test_content_policy_blocked.py +142 -0
  43. tests/agent/test_context_compressor_structural_backoff.py +161 -0
  44. tests/agent/test_context_compressor_summary_continuity.py +461 -0
  45. tests/agent/test_context_compressor_zero_user_provenance.py +459 -0
  46. tests/agent/test_context_refs_concurrent.py +90 -0
  47. tests/agent/test_conversation_loop_interpreter_shutdown.py +68 -0
  48. tests/agent/test_copilot_initiator.py +139 -0
  49. tests/agent/test_create_openai_client_disables_sdk_retries.py +78 -0
  50. 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
+