382 lines
13 KiB
Python
382 lines
13 KiB
Python
"""Regression tests for #73503 — codex_app_server compression must not be a no-op.
|
|
|
|
On the codex_app_server runtime the model's real working context is the
|
|
app-server's server-side thread (CodexAppServerSession is constructed with no
|
|
history and each turn submits only the new user message), so:
|
|
|
|
* the old detached-agent hygiene path could only rewrite the transcript
|
|
mirror — a guaranteed no-op ("compressed 150 -> 150 msgs") — and then
|
|
evicted the live cached agent, destroying the thread that held the only
|
|
real context;
|
|
* the fix routes hygiene and manual /compress to the LIVE cached agent's
|
|
``thread/compact/start`` (asserted here at the compact_thread RPC-stub
|
|
boundary) and keeps that agent cached;
|
|
* ``compression.codex_app_server_auto`` semantics hold: only ``hermes``
|
|
lets Hermes' threshold start a compaction; ``native``/``off`` skip
|
|
cleanly, and no mode ever runs the local transcript compressor.
|
|
"""
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from agent.conversation_compression import compress_context
|
|
from agent.transports.codex_app_server_session import TurnResult
|
|
from gateway.run import run_codex_hygiene_compaction
|
|
|
|
|
|
class FakeCodexSession:
|
|
"""RPC-stub boundary: stands in for the app-server thread client."""
|
|
|
|
def __init__(self, result=None):
|
|
self.result = result or TurnResult(
|
|
thread_id="thread-1", turn_id="compact-1", compacted=True
|
|
)
|
|
self.compact_calls = 0
|
|
self.closed = False
|
|
|
|
def compact_thread(self):
|
|
self.compact_calls += 1
|
|
return self.result
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
class LiveCodexAgent:
|
|
"""Minimal live cached agent whose _compress_context is the REAL routing.
|
|
|
|
The forwarder mirrors run_agent.AIAgent._compress_context: it calls the
|
|
module-level compress_context(), so these tests exercise the genuine
|
|
codex route (mode gate, cooldown, compact_thread RPC) rather than a stub
|
|
of the code under test.
|
|
"""
|
|
|
|
def __init__(self, mode="hermes", session=None):
|
|
self.api_mode = "codex_app_server"
|
|
self.codex_app_server_auto_compaction = mode
|
|
self.session_id = "sess-1"
|
|
self.platform = "telegram"
|
|
self._cached_system_prompt = "cached prompt"
|
|
self._codex_session = session if session is not None else FakeCodexSession()
|
|
self.context_compressor = SimpleNamespace(
|
|
compression_count=0,
|
|
last_compression_rough_tokens=0,
|
|
last_prompt_tokens=100,
|
|
last_completion_tokens=10,
|
|
awaiting_real_usage_after_compression=False,
|
|
)
|
|
self.local_compress_calls = 0
|
|
self.warnings = []
|
|
|
|
# -- surface used by compress_context --------------------------------
|
|
def _touch_activity(self, *a, **k):
|
|
pass
|
|
|
|
def _emit_status(self, m):
|
|
pass
|
|
|
|
def _emit_warning(self, m):
|
|
self.warnings.append(m)
|
|
|
|
def _build_system_prompt(self, s):
|
|
return "built prompt"
|
|
|
|
def _compress_context(self, messages, system_message, **kwargs):
|
|
return compress_context(self, messages, system_message, **kwargs)
|
|
|
|
|
|
def _history(n=150):
|
|
return [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}" * 50}
|
|
for i in range(n)
|
|
]
|
|
|
|
|
|
def _gateway(tmp_path, session_key="tg:123", agent=None):
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
gw = SimpleNamespace(
|
|
_agent_cache={} if agent is None else {session_key: (agent, 0.0)},
|
|
_agent_cache_lock=None,
|
|
_session_db=db,
|
|
)
|
|
return gw, db
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core no-op regression: hermes mode + live thread => thread/compact runs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_hermes_mode_compacts_live_thread_at_rpc_boundary(tmp_path):
|
|
agent = LiveCodexAgent(mode="hermes")
|
|
key = "tg:123"
|
|
gw, db = _gateway(tmp_path, key, agent)
|
|
# Pre-arm a persisted failure streak so success provably resets it
|
|
# through the REAL SessionDB (hygiene path involves the DB).
|
|
assert db.increment_hygiene_failure_streak(key) == 1
|
|
|
|
outcome = asyncio.run(
|
|
run_codex_hygiene_compaction(
|
|
gw,
|
|
key,
|
|
agent.session_id,
|
|
auto_mode="hermes",
|
|
history=_history(),
|
|
approx_tokens=345_000,
|
|
timeout_seconds=30.0,
|
|
)
|
|
)
|
|
|
|
assert outcome == "compacted"
|
|
# The no-op is gone: the thread genuinely shrank via the app-server's own
|
|
# mechanism — asserted at the RPC-stub boundary.
|
|
assert agent._codex_session.compact_calls == 1
|
|
# A compaction boundary was recorded on the live agent's compressor.
|
|
assert agent.context_compressor.compression_count == 1
|
|
# The live agent is STILL cached — hygiene must not evict the thread owner.
|
|
assert gw._agent_cache[key][0] is agent
|
|
# Success reset the persisted hygiene failure streak.
|
|
assert db.increment_hygiene_failure_streak(key) == 1 # was cleared to 0
|
|
|
|
|
|
def test_hermes_mode_without_cached_agent_skips_without_local_compression(tmp_path):
|
|
# Detached case: no live agent → no thread → nothing real to compact.
|
|
# The old behavior "compressed" the mirror (a no-op) and then evicted;
|
|
# the new behavior is an honest skip with the transcript untouched.
|
|
gw, _ = _gateway(tmp_path, agent=None)
|
|
history = _history()
|
|
before = [dict(m) for m in history]
|
|
|
|
outcome = asyncio.run(
|
|
run_codex_hygiene_compaction(
|
|
gw,
|
|
"tg:123",
|
|
"sess-1",
|
|
auto_mode="hermes",
|
|
history=history,
|
|
approx_tokens=345_000,
|
|
timeout_seconds=5.0,
|
|
)
|
|
)
|
|
|
|
assert outcome == "skipped:no-cached-agent"
|
|
assert history == before
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mode semantics: native / off never touch the thread nor run local fallback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("mode", ["native", "off"])
|
|
def test_native_and_off_modes_skip_cleanly(tmp_path, mode):
|
|
agent = LiveCodexAgent(mode=mode)
|
|
key = "tg:123"
|
|
gw, _ = _gateway(tmp_path, key, agent)
|
|
|
|
outcome = asyncio.run(
|
|
run_codex_hygiene_compaction(
|
|
gw,
|
|
key,
|
|
agent.session_id,
|
|
auto_mode=mode,
|
|
history=_history(),
|
|
approx_tokens=345_000,
|
|
timeout_seconds=5.0,
|
|
)
|
|
)
|
|
|
|
assert outcome == f"skipped:mode={mode}"
|
|
# off must not silently compress; native leaves scheduling to codex.
|
|
assert agent._codex_session.compact_calls == 0
|
|
assert agent.context_compressor.compression_count == 0
|
|
# And the agent stays cached in every skip path.
|
|
assert gw._agent_cache[key][0] is agent
|
|
|
|
|
|
def test_unknown_mode_falls_back_to_native_semantics(tmp_path):
|
|
agent = LiveCodexAgent(mode="banana")
|
|
gw, _ = _gateway(tmp_path, "tg:123", agent)
|
|
outcome = asyncio.run(
|
|
run_codex_hygiene_compaction(
|
|
gw,
|
|
"tg:123",
|
|
agent.session_id,
|
|
auto_mode="banana",
|
|
history=_history(),
|
|
approx_tokens=345_000,
|
|
timeout_seconds=5.0,
|
|
)
|
|
)
|
|
assert outcome == "skipped:mode=native"
|
|
assert agent._codex_session.compact_calls == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# force=True (manual /compress) must never violate the "no local fallback"
|
|
# contract: it compacts the THREAD in every mode, and never rewrites the
|
|
# transcript mirror.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("mode", ["native", "hermes", "off"])
|
|
def test_force_compacts_thread_never_local_fallback(mode):
|
|
agent = LiveCodexAgent(mode=mode)
|
|
messages = _history()
|
|
before = [dict(m) for m in messages]
|
|
|
|
returned, prompt = compress_context(
|
|
agent, messages, "system", approx_tokens=345_000, force=True
|
|
)
|
|
|
|
# Thread compaction ran (manual force is an explicit user decision)...
|
|
assert agent._codex_session.compact_calls == 1
|
|
# ...and the local transcript mirror was NOT rewritten in any mode.
|
|
assert returned is messages
|
|
assert returned == before
|
|
assert prompt == "cached prompt"
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["native", "off"])
|
|
def test_force_without_live_thread_does_not_run_local_compressor(mode):
|
|
# The #73715 branch let force=True fall through to the local Hermes
|
|
# compressor in native/off when no thread existed. That is wrong on this
|
|
# runtime in every mode: rewriting the mirror cannot shrink the thread.
|
|
agent = LiveCodexAgent(mode=mode, session=None)
|
|
agent._codex_session = None
|
|
messages = _history()
|
|
before = [dict(m) for m in messages]
|
|
|
|
returned, _ = compress_context(
|
|
agent, messages, "system", approx_tokens=345_000, force=True
|
|
)
|
|
|
|
assert returned is messages
|
|
assert returned == before
|
|
assert agent.context_compressor.compression_count == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Failure handling: a wedged compaction records a DB cooldown (real SessionDB)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_timeout_records_persistent_cooldown(tmp_path):
|
|
class HangingSession(FakeCodexSession):
|
|
def compact_thread(self):
|
|
self.compact_calls += 1
|
|
import time
|
|
|
|
time.sleep(3.0)
|
|
return self.result
|
|
|
|
agent = LiveCodexAgent(mode="hermes", session=HangingSession())
|
|
key = "tg:123"
|
|
gw, db = _gateway(tmp_path, key, agent)
|
|
db.create_session(agent.session_id, "gateway")
|
|
|
|
outcome = asyncio.run(
|
|
run_codex_hygiene_compaction(
|
|
gw,
|
|
key,
|
|
agent.session_id,
|
|
auto_mode="hermes",
|
|
history=_history(),
|
|
approx_tokens=345_000,
|
|
timeout_seconds=1.0,
|
|
failure_cooldown_seconds=300.0,
|
|
)
|
|
)
|
|
|
|
assert outcome == "failed:timeout"
|
|
state = db.get_compression_failure_cooldown(agent.session_id)
|
|
assert state is not None and state.get("remaining_seconds", 0) > 0
|
|
# Even on failure the live agent stays cached — its thread still holds
|
|
# the only real context.
|
|
assert gw._agent_cache[key][0] is agent
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manual /compress gateway surface
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _slash_host(agent, session_key="tg:123"):
|
|
from gateway.slash_commands import GatewaySlashCommandsMixin
|
|
|
|
host = SimpleNamespace(
|
|
_agent_cache={session_key: (agent, 0.0)} if agent is not None else {},
|
|
_agent_cache_lock=None,
|
|
)
|
|
|
|
async def _run_in_executor_with_context(fn):
|
|
return await asyncio.get_running_loop().run_in_executor(None, fn)
|
|
|
|
host._run_in_executor_with_context = _run_in_executor_with_context
|
|
host._compress_codex_app_server_session = (
|
|
GatewaySlashCommandsMixin._compress_codex_app_server_session.__get__(host)
|
|
)
|
|
return host
|
|
|
|
|
|
def test_manual_compress_routes_to_live_thread():
|
|
agent = LiveCodexAgent(mode="off") # even 'off': manual is a user decision
|
|
host = _slash_host(agent)
|
|
|
|
reply = asyncio.run(
|
|
host._compress_codex_app_server_session("tg:123", agent.session_id)
|
|
)
|
|
|
|
assert agent._codex_session.compact_calls == 1
|
|
assert "compacted" in reply
|
|
|
|
|
|
def test_manual_compress_without_live_thread_reports_honestly():
|
|
host = _slash_host(None)
|
|
reply = asyncio.run(
|
|
host._compress_codex_app_server_session("tg:123", "sess-1")
|
|
)
|
|
assert "Nothing to compact" in reply
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multiplexed gateway: the hygiene worker must see the caller's ContextVars
|
|
# (profile secret scope / HERMES_HOME override). A bare run_in_executor worker
|
|
# starts with an EMPTY Context, so get_secret(<PROVIDER>_API_KEY) inside the
|
|
# summary path fails closed and every hygiene compaction degrades to a lossy
|
|
# truncation (#100849 bundle).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_hygiene_worker_inherits_caller_contextvars(tmp_path):
|
|
import contextvars
|
|
import threading
|
|
|
|
marker = contextvars.ContextVar("hygiene_scope_marker", default=None)
|
|
seen = {}
|
|
|
|
class ScopeProbeAgent(LiveCodexAgent):
|
|
def _compress_context(self, messages, system_message, **kwargs):
|
|
seen["value"] = marker.get()
|
|
seen["thread"] = threading.current_thread().name
|
|
return super()._compress_context(messages, system_message, **kwargs)
|
|
|
|
agent = ScopeProbeAgent(mode="hermes")
|
|
key = "tg:ctx"
|
|
gw, _db = _gateway(tmp_path, key, agent)
|
|
|
|
async def _scoped():
|
|
token = marker.set("profile-scope")
|
|
try:
|
|
return await run_codex_hygiene_compaction(
|
|
gw, key, agent.session_id, auto_mode="hermes",
|
|
history=_history(), approx_tokens=345_000, timeout_seconds=30.0,
|
|
)
|
|
finally:
|
|
marker.reset(token)
|
|
|
|
assert asyncio.run(_scoped()) == "compacted"
|
|
assert seen["thread"] != "MainThread", "compaction must still run off-loop"
|
|
assert seen["value"] == "profile-scope", (
|
|
"hygiene worker lost the caller's ContextVars — under multiplex_profiles "
|
|
"this is the UnscopedSecretError / lossy-truncation regression"
|
|
)
|