refactor(desktop): make composer model picker sticky session state

The picker no longer touches the profile default. Model/effort/fast live as
plain UI state persisted in localStorage, so a pick follows across Cmd+N and
restarts instead of snapping back. New chats ship that state through
session.create as per-session overrides; live chats still scope switches to the
current session. Settings -> Model remains the only surface that writes the
profile default.

The gateway now accepts those session.create overrides, builds the agent with
them directly, reflects them in the immediate session.info payload, and writes
the chat's own model_config into the lazy DB row so reconnect/resume restores
that chat instead of the global default.
This commit is contained in:
Brooklyn Nicholson
2026-06-16 09:50:07 -05:00
parent dd0e3e0a05
commit cb6b4127e7
16 changed files with 405 additions and 105 deletions
+161 -6
View File
@@ -1851,8 +1851,10 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, cwd=None):
created.append({"key": key, "source": source, "model": model, "cwd": cwd})
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
created.append(
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
)
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
@@ -1860,7 +1862,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path), "explicit_cwd": True})
assert created == [
{"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
{"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": str(tmp_path)}
]
@@ -1870,15 +1872,74 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path):
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, cwd=None):
created.append({"key": key, "source": source, "model": model, "cwd": cwd})
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
created.append(
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
)
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
assert created == [{"key": "k1", "source": "tui", "model": "test-model", "cwd": None}]
assert created == [
{"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": None}
]
def test_ensure_session_db_row_persists_session_model_override(monkeypatch):
"""The session's composer pick (model + effort + fast) must own the DB row.
Regression for the "switched to gpt-5.5, reconnect snapped back to opus"
bug: the row was created with the global default and won the INSERT-OR-IGNORE
race, so resume rebuilt from the global model and silently reverted the
chat. The override model + a model_config carrying provider/reasoning/
service_tier must be persisted so session.resume restores all three.
"""
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
created.append(
{"key": key, "model": model, "model_config": model_config, "cwd": cwd}
)
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "global/default")
server._ensure_session_db_row(
{
"session_key": "k1",
"model_override": {"model": "openai/gpt-5.5", "provider": "openrouter"},
"create_reasoning_override": {"effort": "high"},
"create_service_tier_override": "priority",
}
)
assert len(created) == 1
row = created[0]
assert row["model"] == "openai/gpt-5.5"
assert row["model_config"]["model"] == "openai/gpt-5.5"
assert row["model_config"]["provider"] == "openrouter"
assert row["model_config"]["reasoning_config"] == {"effort": "high"}
assert row["model_config"]["service_tier"] == "priority"
def test_ensure_session_db_row_no_override_uses_global(monkeypatch):
"""A chat that made no explicit pick falls back to the global model and
writes no model_config (so it tracks the profile default)."""
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
created.append({"model": model, "model_config": model_config})
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "global/default")
server._ensure_session_db_row({"session_key": "k1", "model_override": None})
assert created == [{"model": "global/default", "model_config": None}]
def test_session_title_clears_pending_after_persist(monkeypatch):
@@ -7485,3 +7546,97 @@ def test_reap_idle_sessions_closes_only_evictable(monkeypatch):
assert closed == [("stale", "idle_timeout")]
finally:
server._sessions.clear()
def test_session_create_records_ui_model_as_session_override(monkeypatch):
"""The desktop composer owns its model as plain UI state and ships it on
session.create. The gateway must record it as a PER-SESSION override (built
into the agent), never a global config write picking a model for a new chat
must not mutate the profile default.
"""
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
# Don't run the real deferred build in this storage-focused test.
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
try:
resp = server._methods["session.create"](
"r1",
{
"cols": 80,
"model": "claude-sonnet-4.6",
"provider": "anthropic",
"reasoning_effort": "high",
"fast": True,
},
)
sid = resp["result"]["session_id"]
sess = server._sessions[sid]
assert sess["model_override"] == {"model": "claude-sonnet-4.6", "provider": "anthropic"}
assert sess["create_reasoning_override"] is not None
assert sess["create_service_tier_override"] == "priority"
# The immediate response reflects the override (not the global default) so
# the client never clobbers its sticky pick before the build lands.
assert resp["result"]["info"]["model"] == "claude-sonnet-4.6"
assert resp["result"]["info"]["provider"] == "anthropic"
# No knobs → no overrides; the session builds from the profile default.
plain = server._methods["session.create"]("r2", {"cols": 80})
plain_sess = server._sessions[plain["result"]["session_id"]]
assert plain_sess["model_override"] is None
assert plain_sess["create_reasoning_override"] is None
assert plain_sess["create_service_tier_override"] is None
finally:
server._sessions.clear()
def test_start_agent_build_passes_session_model_override(monkeypatch):
"""A model staged on the session (e.g. by session.create from the desktop
composer) must reach _make_agent so the first build runs on it directly
no global config, no build-then-switch.
"""
captured = {}
class FakeWorker:
def __init__(self, *_a, **_k):
pass
def close(self):
pass
def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
captured.update(kwargs)
return types.SimpleNamespace(model="claude-sonnet-4.6")
monkeypatch.setattr(server, "_set_session_context", lambda target: [])
monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
monkeypatch.setattr(server, "_make_agent", fake_make_agent)
monkeypatch.setattr(server, "_SlashWorker", FakeWorker)
monkeypatch.setattr(server, "_attach_worker", lambda *a, **k: None)
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
monkeypatch.setattr(server, "_emit", lambda *a, **k: None)
monkeypatch.setattr(server, "_session_info", lambda *a, **k: {})
monkeypatch.setattr(server, "_start_notification_poller", lambda *a, **k: None)
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **k: None)
monkeypatch.setattr(server, "_probe_config_health", lambda *_a: None)
sid = "build-sid"
override = {"model": "claude-sonnet-4.6", "provider": "anthropic"}
reasoning = {"enabled": True, "effort": "high"}
session = {
"agent": None,
"agent_ready": threading.Event(),
"session_key": "k1",
"profile_home": None,
"model_override": override,
"create_reasoning_override": reasoning,
"create_service_tier_override": "priority",
}
server._sessions[sid] = session
try:
server._start_agent_build(sid, session)
assert session["agent_ready"].wait(timeout=3), "agent build did not finish"
assert captured.get("model_override") == override
assert captured.get("reasoning_config_override") == reasoning
assert captured.get("service_tier_override") == "priority"
assert session["agent"].model == "claude-sonnet-4.6"
finally:
server._sessions.clear()