A codex thread that codex hands back via thread/resume already holds the conversation, but a thread started fresh did not: a session that ran on another provider before /model switched to openai-codex, a stored thread codex could not resume, or a thread retired mid-session (prompt composition change, wedged client) answered the first turn blind. The prior user/assistant text, tool names and tool-result previews (most recent 32K chars) now ride once on thread/start.developerInstructions after the prompt composition; thread/resume never carries them, and the recorded composition stays the bare prompt so the seed cannot make the next turn retire the thread. Direction from #26081 (first-turn seeding of the Hermes transcript); redone on the extracted agent/codex_runtime.py path with the system prompt sent once (#115759) instead of duplicated. Completes #26035 / #74712 (closed by #115759 for the prompt half; this is the history half). Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com>
86 lines
4.1 KiB
Python
86 lines
4.1 KiB
Python
"""A codex app-server thread started from scratch is seeded with the session's prior turns (#26035, #74712).
|
|
|
|
Direction from #26081 (@LeonSGP43). A resumed codex thread already holds the conversation, so only a
|
|
fresh ``thread/start`` carries the seed, and the recorded prompt composition stays the bare prompt so
|
|
the seed never makes the next turn retire the thread.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from agent import codex_runtime
|
|
from agent.transports import codex_app_server_session as sess_mod
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self, **_kw):
|
|
self.requests = []
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
def initialize(self, **_kw):
|
|
return {}
|
|
|
|
def request(self, method, params=None, timeout=None):
|
|
self.requests.append((method, params))
|
|
return {"thread": {"id": (params or {}).get("threadId", "fresh")}}
|
|
|
|
|
|
def _agent(**overrides):
|
|
base = dict(_codex_session=None, session_cwd="/tmp", tool_progress_callback=None,
|
|
_cached_system_prompt="SOUL: you are Hermes", ephemeral_system_prompt=None)
|
|
base.update(overrides)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
_HISTORY = [
|
|
{"role": "system", "content": "SOUL: you are Hermes"},
|
|
{"role": "user", "content": "my dog is called Shadow"},
|
|
{"role": "assistant", "content": "Noted: Shadow.", "tool_calls": [{"function": {"name": "memory"}}]},
|
|
{"role": "tool", "content": "saved"},
|
|
{"role": "user", "content": "what is my dog called?"}, # the turn being submitted
|
|
]
|
|
|
|
|
|
def test_fresh_thread_is_seeded_with_prior_turns_but_not_the_current_one(monkeypatch):
|
|
client = _FakeClient()
|
|
monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client)
|
|
agent = _agent()
|
|
codex_runtime._ensure_codex_session(agent, _HISTORY)
|
|
agent._codex_session.ensure_started()
|
|
(_, params), = [(m, p) for (m, p) in client.requests if m == "thread/start"]
|
|
instructions = params["developerInstructions"]
|
|
assert instructions.startswith("SOUL: you are Hermes")
|
|
assert "my dog is called Shadow" in instructions and "Noted: Shadow." in instructions
|
|
assert "called tools: memory" in instructions and "saved" in instructions
|
|
assert "what is my dog called?" not in instructions
|
|
assert instructions.count("SOUL: you are Hermes") == 1 # system rows are not re-rendered as history
|
|
# The seed is not part of the recorded composition: the next turn keeps the thread.
|
|
assert agent._codex_session_prompt == "SOUL: you are Hermes"
|
|
codex_runtime._ensure_codex_session(agent, _HISTORY + [{"role": "assistant", "content": "Shadow"}])
|
|
assert len([m for (m, _) in client.requests if m == "thread/start"]) == 1
|
|
|
|
|
|
def test_resumed_thread_gets_no_seed_but_a_failed_resume_fallback_does(monkeypatch):
|
|
"""thread/resume already holds the conversation; the fresh thread started after a failed resume does not."""
|
|
client = _FakeClient()
|
|
monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client)
|
|
db = SimpleNamespace(get_session_model_config_value=lambda *_: "stored-thread", patch_session_model_config=lambda *_: None)
|
|
agent = _agent(_session_db=db, session_id="s1", _emit_diagnostic_status=lambda *_: None)
|
|
codex_runtime._ensure_codex_session(agent, _HISTORY)
|
|
codex_runtime._start_codex_thread(agent)
|
|
(_, resume_params), = [(m, p) for (m, p) in client.requests if m == "thread/resume"]
|
|
assert "my dog is called Shadow" not in resume_params.get("developerInstructions", "")
|
|
|
|
failing = _FakeClient()
|
|
failing.request = lambda method, params=None, timeout=None: (
|
|
(_ for _ in ()).throw(sess_mod.CodexAppServerError(code=-32602, message="unknown thread"))
|
|
if method == "thread/resume" else (failing.requests.append((method, params)) or {"thread": {"id": "fresh"}})
|
|
)
|
|
monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: failing)
|
|
agent = _agent(_session_db=db, session_id="s1", _emit_diagnostic_status=lambda *_: None)
|
|
codex_runtime._ensure_codex_session(agent, _HISTORY)
|
|
assert codex_runtime._start_codex_thread(agent) == "fresh"
|
|
(_, start_params), = [(m, p) for (m, p) in failing.requests if m == "thread/start"]
|
|
assert "my dog is called Shadow" in start_params["developerInstructions"]
|