Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions coding_bridge/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions coding_bridge/providers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 1 addition & 5 deletions coding_bridge/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
20 changes: 11 additions & 9 deletions coding_bridge/session_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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._-]+$")


Expand All @@ -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)
Expand Down
10 changes: 4 additions & 6 deletions tests/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand All @@ -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"

Expand All @@ -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"
1 change: 0 additions & 1 deletion tests/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
8 changes: 6 additions & 2 deletions tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,19 @@ 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
saved = session_meta.load(tmp_path, "disk-9")
assert saved == {
"version": 2,
"cwd": "/repo",
"model_selector": "opus",
"model": "opus",
"permission_mode": "acceptEdits",
"effort": "high",
"provider": "claude",
Expand Down
14 changes: 7 additions & 7 deletions tests/test_session_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,34 @@ 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",
)
loaded = session_meta.load(tmp_path, "sid-1")
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}

Expand Down Expand Up @@ -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")
Expand Down
Loading