diff --git a/coding_bridge/connection.py b/coding_bridge/connection.py index 89b6733..47df3ac 100644 --- a/coding_bridge/connection.py +++ b/coding_bridge/connection.py @@ -549,9 +549,8 @@ async def _send_history_detail(self, payload: dict) -> None: ) return detail = await asyncio.to_thread(history.read_session, provider, session_id) - # A transcript records the provider-resolved model, not the exact selector - # that launched it. Keep those fields separate: only a versioned sidecar can - # supply `model_selector`; legacy `model` values have ambiguous provenance. + # Transcript model IDs are resolved identities. Only a schema-v2 sidecar + # can supply the launch `model`; legacy sidecars have ambiguous provenance. resolved_model = detail.pop("model", None) meta = session_meta.load(self.settings.config_dir, session_id) if meta.get("permission_mode"): @@ -560,14 +559,11 @@ async def _send_history_detail(self, payload: dict) -> None: detail["effort"] = meta["effort"] if not detail.get("cwd") and meta.get("cwd"): detail["cwd"] = meta["cwd"] - selector = meta.get("model_selector") - if isinstance(selector, str): - detail["model_selector"] = selector - # Compatibility for older browser clients: `model` is selector-only. - detail["model"] = selector + model = meta.get("model") if meta.get("version") == 2 else None + if isinstance(model, str): + detail["model"] = model if isinstance(resolved_model, str): detail["resolved_model"] = resolved_model - detail["model_contract"] = 2 await self.send_payload(event_payload(Event.HISTORY_DETAIL, session_id, **detail)) async def _send_fs_list(self, payload: dict) -> None: diff --git a/coding_bridge/providers/claude.py b/coding_bridge/providers/claude.py index 435513a..fe1429d 100644 --- a/coding_bridge/providers/claude.py +++ b/coding_bridge/providers/claude.py @@ -240,7 +240,7 @@ async def _ensure_client( ) from exc resume_id = claude_transcript.prepare_resume(resume) if resume else None logger.info( - "claude launch session=%s resume=%s model_selector=%r", + "claude launch session=%s resume=%s model=%r", resume or self._sdk_session_id or self._session_id, bool(resume), model, @@ -410,7 +410,7 @@ def _note_system(self, message: Any) -> None: resolved_model = data.get("model") if isinstance(data, dict) else None if version or resolved_model: logger.info( - "claude init version=%s session=%s model_selector=%r resolved_model=%r", + "claude init version=%s session=%s model=%r resolved_model=%r", version, self._session_id, self._model, diff --git a/coding_bridge/session.py b/coding_bridge/session.py index d7e895e..a72a62b 100644 --- a/coding_bridge/session.py +++ b/coding_bridge/session.py @@ -128,7 +128,7 @@ def _remember_settings(self, sdk_session_id: str) -> None: sdk_session_id, provider=self.provider, cwd=self.cwd, - model_selector=self.model, + model=self.model, permission_mode=self.permission_mode, effort=self.effort, ) @@ -171,8 +171,6 @@ async def start( self.session_id, cwd=self.cwd, model=self.model, - model_selector=self.model, - model_contract=2, provider=self.provider, permission_mode=self.permission_mode, effort=self.effort, @@ -358,8 +356,6 @@ def info(self) -> dict[str, Any]: "provider": self.provider, "cwd": self.cwd, "model": self.model, - "model_selector": self.model, - "model_contract": 2, "permission_mode": self.permission_mode, "effort": self.effort, } diff --git a/coding_bridge/session_meta.py b/coding_bridge/session_meta.py index 0e03750..1cde84b 100644 --- a/coding_bridge/session_meta.py +++ b/coding_bridge/session_meta.py @@ -15,11 +15,10 @@ from . import store -# Version 2 distinguishes the exact launch selector from the provider-resolved -# model recorded in transcripts. A legacy `model` value has ambiguous provenance -# and must never be promoted to a selector. +# In schema v2, `model` has one meaning: the exact launch selection. Transcript +# model IDs are exposed separately as `resolved_model` and never enter this file. _SCHEMA_VERSION = 2 -_FIELDS = ("version", "cwd", "model_selector", "permission_mode", "effort", "provider") +_FIELDS = ("version", "cwd", "model", "permission_mode", "effort", "provider") _SAFE_ID = re.compile(r"^[A-Za-z0-9._-]+$") @@ -36,14 +35,17 @@ def save(config_dir: Path | str, sid: str, **fields: Any) -> None: path = _path(config_dir, sid) if path is None: return - selector_supplied = "model_selector" in fields + model_supplied = "model" in fields data = {k: v for k, v in fields.items() if k in _FIELDS and v is not None} - if not data and not selector_supplied: + if not data and not model_supplied: return existing = store.load(path) or {} - existing.pop("model", None) - if selector_supplied and fields["model_selector"] is None: - existing.pop("model_selector", None) + # A v1 `model` may be a resolved transcript ID. Drop it during migration; + # only the caller-provided v2 value below has launch-selection provenance. + if existing.get("version") != _SCHEMA_VERSION: + existing.pop("model", None) + if model_supplied and fields["model"] is None: + existing.pop("model", None) existing.update(data) existing["version"] = _SCHEMA_VERSION store.save(path, existing) diff --git a/tests/test_connection.py b/tests/test_connection.py index 69fbe29..9ac8ac9 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -682,10 +682,9 @@ async def test_history_detail_separates_selector_from_resolved_model(tmp_path, m conn, monkeypatch, "claude-opus-5", - {"version": 2, "provider": "claude", "model_selector": "opus[1m]"}, + {"version": 2, "provider": "claude", "model": "opus[1m]"}, ) - assert detail["model_selector"] == "opus[1m]" - assert detail["model"] == "opus[1m]" # compatibility: selector-only + assert detail["model"] == "opus[1m]" assert detail["resolved_model"] == "claude-opus-5" @@ -697,7 +696,6 @@ async def test_history_detail_does_not_resume_from_polluted_legacy_model(tmp_pat "claude-opus-5", {"provider": "claude", "model": "claude-opus-5"}, ) - assert "model_selector" not in detail assert "model" not in detail assert detail["resolved_model"] == "claude-opus-5" @@ -708,7 +706,7 @@ async def test_history_detail_roundtrips_explicit_bare_selector(tmp_path, monkey conn, monkeypatch, "claude-opus-5", - {"version": 2, "provider": "claude", "model_selector": "opus"}, + {"version": 2, "provider": "claude", "model": "opus"}, ) - assert detail["model_selector"] == "opus" + assert detail["model"] == "opus" assert detail["resolved_model"] == "claude-opus-5" diff --git a/tests/test_history.py b/tests/test_history.py index 44f3cea..1e850a2 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -820,7 +820,6 @@ async def test_dispatch_history_get_folds_in_sidecar(monkeypatch, tmp_path): assert detail["effort"] == "high" assert detail["cwd"] == "/repo" # transcript stays authoritative assert detail["resolved_model"] == "opus" - assert "model_selector" not in detail assert "model" not in detail diff --git a/tests/test_session.py b/tests/test_session.py index 37ee302..83966f3 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -169,7 +169,11 @@ async def ask(*_a): sess = Session( "s1", factory, emit, settings, - cwd="/repo", model="opus", permission_mode="acceptEdits", provider="claude", effort="high", + cwd="/repo", + model="opus", + permission_mode="acceptEdits", + provider="claude", + effort="high", ) await sess.start("hi") await sess._task @@ -177,7 +181,7 @@ async def ask(*_a): assert saved == { "version": 2, "cwd": "/repo", - "model_selector": "opus", + "model": "opus", "permission_mode": "acceptEdits", "effort": "high", "provider": "claude", diff --git a/tests/test_session_meta.py b/tests/test_session_meta.py index 8b4da3f..bce1318 100644 --- a/tests/test_session_meta.py +++ b/tests/test_session_meta.py @@ -7,7 +7,7 @@ def test_save_then_load_roundtrip(tmp_path): tmp_path, "sid-1", cwd="/repo", - model_selector="opus[1m]", + model="opus[1m]", permission_mode="plan", effort="high", ) @@ -15,26 +15,26 @@ def test_save_then_load_roundtrip(tmp_path): assert loaded == { "version": 2, "cwd": "/repo", - "model_selector": "opus[1m]", + "model": "opus[1m]", "permission_mode": "plan", "effort": "high", } def test_save_merges_and_drops_none(tmp_path): - session_meta.save(tmp_path, "sid-1", cwd="/repo", model_selector="opus") + session_meta.save(tmp_path, "sid-1", cwd="/repo", model="opus") # A later turn only changes effort/mode; cwd/selector must survive the merge. session_meta.save(tmp_path, "sid-1", permission_mode="acceptEdits", effort=None) loaded = session_meta.load(tmp_path, "sid-1") assert loaded["cwd"] == "/repo" - assert loaded["model_selector"] == "opus" + assert loaded["model"] == "opus" assert loaded["permission_mode"] == "acceptEdits" assert "effort" not in loaded def test_explicit_default_clears_a_saved_selector(tmp_path): - session_meta.save(tmp_path, "sid-1", model_selector="opus[1m]") - session_meta.save(tmp_path, "sid-1", model_selector=None) + session_meta.save(tmp_path, "sid-1", model="opus[1m]") + session_meta.save(tmp_path, "sid-1", model=None) loaded = session_meta.load(tmp_path, "sid-1") assert loaded == {"version": 2} @@ -64,7 +64,7 @@ def test_legacy_resolved_model_is_not_promoted_to_selector(tmp_path): ) legacy = session_meta.load(tmp_path, "sid-1") assert legacy["model"] == "claude-opus-5" - assert "model_selector" not in legacy + assert "version" not in legacy session_meta.save(tmp_path, "sid-1", effort="high") migrated = session_meta.load(tmp_path, "sid-1")