Files
hermes-agent/tests/agent/test_codex_app_server_thread_resume.py
Teknium a274d3388f fix: core modules import again on Python 3.11-3.13 (re-quote eager forward references)
#135348 ran ruff's UP037 autofix with ruff.strict.toml's target-version = "py314". On 3.14
annotations are lazy (PEP 649), so stripping quotes is safe there; on 3.11-3.13 a function or
variable annotation is evaluated when the def or statement runs. Every unquoted annotation that
names the enclosing class (`def clone_for_agent(self) -> ContextEngine`), a class bound later in
the module, or a TYPE_CHECKING-only import now raises NameError on import: 300 modules on main
(gateway.config, run_agent, hermes_state, cron.jobs, the Telegram adapter, ...) fail to import
under 3.11, the update path crashes mid-`hermes update` on any install whose interpreter is
older than 3.14, and the e2e-upgrade suite's retry leg fails with
`NameError: name 'ContextEngine' is not defined`.

- Re-quote exactly the 79 annotations (36 files) that name something unbound when they are
  evaluated (including tui_gateway/server.py's `_TurnScopes`, which only reaches that module's
  globals through register() after import); no other text changes.
- agent/turn_scripted_prelude.py: `Generator[...]` gets its third argument back. d76cce4f11
  applied UP043 (default type args, valid only from 3.13) to a module-level alias, so
  `Generator[Y, S]` raises TypeError on 3.11/3.12 and takes gateway.run, agent.conversation_loop
  and tui_gateway.{server,ws,entry} down with it.
- ruff.strict.toml target-version = "py311", the requires-python floor. With it UP037 leaves
  these quotes alone and only strips the two that are genuinely redundant, which this commit
  strips; any future version-gated fix stays valid on every supported Python.
- tests/ci/test_strict_ruff_target.py pins the target to the requires-python floor (red on main).
2026-10-09 14:25:07 -07:00

96 lines
4.6 KiB
Python

"""#100531 — the codex app-server thread survives an AIAgent rebuild (API-server restart, per-request agents).
``CodexAppServerSession`` keeps the codex thread id in memory only, so every new ``AIAgent`` for the same
Hermes session used to ``thread/start`` an empty thread while Hermes' own transcript continued. The runtime
now publishes ``codex_thread_id`` into the session row's ``model_config`` once the turn's projected rows are
durable, the next agent for that session issues ``thread/resume`` for it, and a stored id codex cannot hand
back fails closed: fresh thread, binding dropped, one status-rail notice.
"""
from pathlib import Path
from agent.transports import codex_app_server_session as session_mod
from agent.transports.codex_app_server import CodexAppServerError
from agent.transports.codex_app_server_session import CodexAppServerSession, TurnResult
from hermes_state import SessionDB
SID = "sess-codex-restart"
class _WireClient:
"""Minimal app-server stand-in: answers thread/start with a fresh id, thread/resume with the requested
id (or refuses ids in ``dead``), and records every JSON-RPC method it saw."""
dead: set[str] = set()
instances: "list[_WireClient]" = []
counter = 0
def __init__(self, **kwargs):
self.requests: list[tuple[str, dict]] = []
_WireClient.instances.append(self)
def initialize(self, **kwargs):
return {}
def request(self, method, params=None, timeout=30.0):
params = params or {}
self.requests.append((method, params))
if method == "thread/resume":
if params["threadId"] in _WireClient.dead:
raise CodexAppServerError(code=-32600, message=f"no rollout found for thread id {params['threadId']}")
return {"thread": {"id": params["threadId"]}}
_WireClient.counter += 1
return {"thread": {"id": f"thread-{_WireClient.counter}"}}
def close(self):
pass
def _agent(db, **kwargs):
from run_agent import AIAgent
agent = AIAgent(api_key="stub", base_url="https://stub.invalid", provider="openai", api_mode="codex_app_server",
quiet_mode=True, skip_context_files=True, skip_memory=True, session_db=db, session_id=SID, **kwargs)
agent._spawn_background_review = lambda **kw: None
return agent
def _run_turn(self, user_input, **kwargs):
return TurnResult(final_text=f"echo {user_input}", thread_id=self._thread_id, turn_id="turn-1",
projected_messages=[{"role": "assistant", "content": f"echo {user_input}"}])
def test_rebuilt_agent_resumes_the_stored_codex_thread_and_an_unresumable_one_fails_closed(monkeypatch, tmp_path):
monkeypatch.setattr(session_mod, "CodexAppServerClient", _WireClient)
monkeypatch.setattr(CodexAppServerSession, "run_turn", _run_turn)
_WireClient.instances, _WireClient.dead, _WireClient.counter = [], set(), 0
db = SessionDB(Path(tmp_path) / "state.db")
try:
# Process 1: first turn publishes the binding only after the transcript is durable.
first = _agent(db)
assert first.run_conversation("Remember the word amber.")["completed"]
assert db.get_session_model_config_value(SID, "codex_thread_id") == "thread-1"
assert [r["content"] for r in db.get_messages(SID)][-1] == "echo Remember the word amber."
# Process 2 (restart): a new AIAgent for the same session resumes that thread before turn/start.
notices: list[str] = []
def status_callback(kind, message): # the lifecycle rail every surface renders; other kinds are noise
if kind == "lifecycle":
notices.append(str(message))
second = _agent(db)
second.status_callback = status_callback
assert second.run_conversation("Which word?", conversation_history=[])["completed"]
assert [m for m, _ in _WireClient.instances[1].requests] == ["thread/resume"]
assert _WireClient.instances[1].requests[0][1]["threadId"] == "thread-1"
assert notices == []
# Control: the stored thread is gone on the codex side -> fresh thread, binding rotated, one notice.
_WireClient.dead = {"thread-1"}
third = _agent(db)
third.status_callback = status_callback
assert third.run_conversation("And now?", conversation_history=[])["completed"]
assert [m for m, _ in _WireClient.instances[2].requests] == ["thread/resume", "thread/start"]
assert db.get_session_model_config_value(SID, "codex_thread_id") == "thread-2"
assert notices == ["Codex thread could not be resumed; starting a new one."]
finally:
db.close()