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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user