Files
hermes-agent/tests/agent/test_codex_app_server_thread_resume.py
teknium1 e77e24a6a6 fix: persist the codex thread id per session and thread/resume it across an API-server restart
After a codex app-server turn's projected rows are durable in the session DB,
store the codex thread id as ``codex_thread_id`` in the session row's
model_config (atomic merge via patch_session_model_config; never for a retired
thread). The FIRST CodexAppServerSession an AIAgent builds for that session
passes the stored id as resume_thread_id, so a rebuilt agent — the next
/api/sessions/{id}/chat request, or the first turn after the API server or
gateway restarts — resumes the model-side thread before turn/start instead of
starting an empty one while Hermes' own transcript continues.

Fail closed when codex cannot hand the thread back (rollout gone, CODEX_HOME
changed, previous app-server killed mid-write): drop the stored id, start a
fresh thread on the same client, and say so once —
"Codex thread could not be resumed; starting a new one." — through
_emit_diagnostic_status, the lifecycle status rail every surface renders (CLI
vprint, TUI/Desktop and gateway status_callback). No other lifecycle change:
a retired or prompt-recreated session in the same process keeps today's
fresh-thread behaviour and overwrites the binding once its turn commits.

Why: CodexAppServerSession kept the thread id in memory only, so every
API-server restart (and every per-request agent) silently reset the model's
memory of the conversation (#100531). Supersedes the persistence half of
#100528 (_persist_projected_messages now reports durability) and the
refuse-and-raise policy of #103352 with the maintainer-approved bounded slice.
2026-09-19 12:27:01 -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()