fix(tui): reject /model and agent-mutating slash passthroughs while running (#12548)

agent.switch_model() mutates self.model, self.provider, self.base_url,
self.api_key, self.api_mode, and rebuilds self.client / self._anthropic_client
in place.  The worker thread running agent.run_conversation reads those
fields on every iteration.  A concurrent config.set key=model or slash-
worker-mirrored /model / /personality / /prompt / /compress can send an
HTTP request with mismatched model + base_url (or the old client keeps
running against a new endpoint) — 400/404s the user never asked for.

Fix: same pattern as the session.undo / session.compress guards
(PR #12416) and the gateway runner's running-agent /model guard (PR
#12334).  Reject with 4009 'session busy' when session.running is True.

Two call sites guarded:
- config.set with key=model: primary /model entry point from Ink
- _mirror_slash_side_effects for model / personality / prompt /
  compress: slash-worker passthrough path that applies live-agent
  side effects

Idle sessions still switch models normally — regression guard test
verifies this.

Tests (tests/test_tui_gateway_server.py): 4 new cases.
- test_config_set_model_rejects_while_running
- test_config_set_model_allowed_when_idle (regression guard)
- test_mirror_slash_side_effects_rejects_mutating_commands_while_running
- test_mirror_slash_side_effects_allowed_when_idle (regression guard)

Validated: against unpatched server.py, the two 'rejects_while_running'
tests fail with the exact race they assert against.  With the fix all
4 pass.  Live E2E against the live Python environment confirmed both
guards enforce 4009 / 'session busy' exactly as designed.
This commit is contained in:
Teknium 2026-04-19 05:19:57 -07:00 committed by GitHub
parent a3b76ae36d
commit d5fc8a5e00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 145 additions and 0 deletions

View file

@ -1743,6 +1743,19 @@ def _(rid, params: dict) -> dict:
if not value:
return _err(rid, 4002, "model value required")
if session:
# Reject during an in-flight turn. agent.switch_model()
# mutates self.model / self.provider / self.base_url /
# self.client in place; the worker thread running
# agent.run_conversation is reading those on every
# iteration. A mid-turn swap can send an HTTP request
# with the new base_url but old model (or vice versa),
# producing 400/404s the user never asked for. Parity
# with the gateway's running-agent /model guard.
if session.get("running"):
return _err(
rid, 4009,
"session busy — /interrupt the current turn before switching models",
)
result = _apply_model_switch(params.get("session_id", ""), session, value)
else:
result = _apply_model_switch("", {"agent": None}, value)
@ -2446,6 +2459,17 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
return ""
name, arg, agent = parts[0], (parts[1].strip() if len(parts) > 1 else ""), session.get("agent")
# Reject agent-mutating commands during an in-flight turn. These
# all do read-then-mutate on live agent/session state that the
# worker thread running agent.run_conversation is using. Parity
# with the session.compress / session.undo guards and the gateway
# runner's running-agent /model guard.
_MUTATES_WHILE_RUNNING = {"model", "personality", "prompt", "compress"}
if name in _MUTATES_WHILE_RUNNING and session.get("running"):
return (
f"session busy — /interrupt the current turn before running /{name}"
)
try:
if name == "model" and arg and agent:
result = _apply_model_switch(sid, session, arg)