fix(codex): an in-place /model switch reaches the live app-server thread

The CLI/TUI /model switch mutates the running agent, and its codex session
(and thread) is reused, so the model fixed at thread/start stayed in force
for the rest of the session. turn/start accepts a model that codex applies
to that turn and later ones (codex 0.147 schema; live: a thread started on
codex's default ran a turn on gpt-5.5 with modelContextWindow 258400).
Every turn now sends the agent's current wire model.
This commit is contained in:
kshitijk4poor
2026-10-01 22:21:54 +05:30
committed by kshitij
parent 922eb16da7
commit 78cd32c81c
4 changed files with 49 additions and 7 deletions

View File

@@ -679,7 +679,7 @@ def run_codex_app_server_turn(agent, *, user_message: str, original_user_message
_ensure_codex_session(agent, messages)
try:
_start_codex_thread(agent)
turn = agent._codex_session.run_turn(user_input=user_message)
turn = agent._codex_session.run_turn(user_input=user_message, model=_codex_wire_model(agent))
except Exception as exc:
logger.exception("codex app-server turn failed")
_close_codex_session(agent)

View File

@@ -444,11 +444,14 @@ class CodexAppServerSession:
return projection, aborted
def run_turn(
self, user_input: Any, *, turn_timeout: float = 600.0,
self, user_input: Any, *, model: Optional[str] = None, turn_timeout: float = 600.0,
notification_poll_timeout: float = 0.25, post_tool_quiet_timeout: float = 90.0,
) -> TurnResult:
"""Send a user message and block until turn/completed, bridging approvals and projecting items.
model: sent on ``turn/start`` (codex applies it to this and later turns), so an in-place ``/model``
switch reaches a thread that was started with another model.
post_tool_quiet_timeout: if codex emits a tool completion and then goes quiet for this many seconds
without emitting another item or `turn/completed`, log a warning (once per tool result) and keep
waiting. Wire silence is not evidence of a wedged process: after a large tool output codex can
@@ -463,11 +466,10 @@ class CodexAppServerSession:
result.interrupted = True
else:
input_items, result.submitted_user_text = _build_turn_input(user_input)
ts = self._request_for(
result, "turn/start",
{"threadId": self._thread_id, "input": input_items},
"turn/start",
)
params: dict[str, Any] = {"threadId": self._thread_id, "input": input_items}
if model:
params["model"] = model
ts = self._request_for(result, "turn/start", params, "turn/start")
if ts is not None:
self._run_started_turn(result, ts, turn_timeout, notification_poll_timeout, post_tool_quiet_timeout)
self._interrupt_event.clear()

View File

@@ -73,3 +73,31 @@ def test_runtime_retires_thread_when_prompt_composition_changes(monkeypatch):
starts = [p["developerInstructions"] for (m, p) in client.requests if m == "thread/start"]
assert starts == ["SOUL: you are Hermes\n\nAlways start with ZZZ", "SOUL: you are Hermes\n\nPersonality: pirate"]
assert client.closed == 1 # the stale thread's client was closed, not leaked
def test_each_turn_sends_the_agent_s_current_wire_model(monkeypatch):
"""CLI/TUI ``/model`` mutates the live agent; every turn/start carries the model selected now (the
``-900k`` alias as its base slug), not the one the thread was started with."""
turns = []
class _Session:
def __init__(self, **_kw):
pass
def ensure_started(self):
return "t1"
def run_turn(self, **kw):
turns.append(kw["model"])
raise RuntimeError("stop after turn/start")
def close(self):
pass
monkeypatch.setattr(sess_mod, "CodexAppServerSession", _Session)
agent = _agent(model="gpt-5.6-sol-900k", _interrupt_requested=False, _interrupt_message=None)
for model in ("gpt-5.6-sol-900k", "gpt-5.5"):
agent.model = model
codex_runtime.run_codex_app_server_turn(agent, user_message="hi", original_user_message="hi",
messages=[], effective_task_id="t")
assert turns == ["gpt-5.6-sol", "gpt-5.5"]

View File

@@ -311,6 +311,18 @@ class TestRunTurn:
assert result.submitted_user_text == params["input"][0]["text"]
assert result.submitted_user_text != rich_input
def test_turn_start_carries_the_turn_model_only_when_given(self):
"""An in-place ``/model`` switch keeps the session; the model rides on turn/start, which codex
applies to this and later turns of the existing thread."""
sent = []
for model in ("gpt-5.5", None):
client = FakeClient()
client.queue_notification("turn/completed", threadId="t",
turn={"id": "turn-fake-001", "status": "completed", "error": None})
make_session(client).run_turn("hi", model=model, turn_timeout=2.0)
sent.append(next(p for (m, p) in client.requests if m == "turn/start").get("model"))
assert sent == ["gpt-5.5", None]
def test_foreign_completion_in_server_request_drain_is_ignored(self):
"""Approval draining must not project a child result into the parent."""
client = FakeClient()