Some checks failed
Release & Docker / release (push) Has been cancelled
* stage bg_task trio combined (#2979 superset) on master for deep review * fix(bg_task): unsubscribe SessionChannel on header-write failure (Codex deep-review catch) + regression test * test: realign on-subscribe-recovery anchor to subscribe_to_session_channel after leak fix * CHANGELOG: bg_task trio as v0.51.340 LD (HELD pending independent review) * bg_task trio: apply 3 independent-review (greptile) fixes 1. start_session_turn now threads the session PROFILE model defaults (_read_profile_model_config) into the wakeup model-resolve, so a brand-new session with an empty model falls back to the profile default not global DEFAULT_MODEL. Updated the white-box spy test signature accordingly. 2. /api/session/stream omits the Connection header (HTTP/1.1 keep-alive default) to match the #3103 long-lived-SSE pattern. 3. Reaper now prunes _LAST_EMIT_TS for collected sessions so the coalesce timestamp map can't grow one permanent entry per session forever. nesquena APPROVED the PR; these are the 3 non-blocking greptile suggestions. * test: realign _start_session_turn adapter stub lambda to new profile-defaults signature
134 lines
5.2 KiB
Python
134 lines
5.2 KiB
Python
"""Regression: GET /api/session display resolvers must never trigger the
|
|
live provider-catalog rebuild.
|
|
|
|
Root cause (multi-tab streaming interlock RCA, task t_d127953d):
|
|
``_resolve_effective_session_model_for_display`` /
|
|
``_resolve_effective_session_model_provider_for_display`` are called by the
|
|
hot, side-effect-free ``GET /api/session?...&resolve_model=1`` path. When a
|
|
session has no persisted ``model_provider`` (common — e.g. kanban/imported
|
|
sessions), the fast path in ``_resolve_compatible_session_model_state`` is
|
|
skipped and the resolver fell through to ``get_available_models()`` WITHOUT
|
|
``prefer_cache``. On a non-AWS / WSL / corp network that cold rebuild blocks
|
|
~10s on a botocore IMDS probe (plus anthropic/openrouter /models) and, run
|
|
concurrently across browser tabs, serializes on the models-cache lock and
|
|
starves SSE/streaming -> BrokenPipe/Cancelled storm.
|
|
|
|
This is an INVARIANT test, not a change-detector: it asserts the resolvers
|
|
resolve from the cache-only path and never reach the live-rebuild seam
|
|
``api.config._invoke_models_rebuild`` — regardless of whether the session
|
|
carries a model_provider.
|
|
"""
|
|
|
|
import ast
|
|
import inspect
|
|
|
|
import pytest
|
|
|
|
import api.config as cfg
|
|
import api.routes as routes
|
|
|
|
|
|
class _FakeSession:
|
|
"""Minimal stand-in for a Session row as seen by the display resolvers."""
|
|
|
|
def __init__(self, model, model_provider):
|
|
self.model = model
|
|
self.model_provider = model_provider
|
|
|
|
|
|
@pytest.fixture
|
|
def cold_models_cache(monkeypatch):
|
|
"""Force a cold in-memory + disk models cache without touching real state.
|
|
|
|
Cold cache is what makes the regression observable: a warm cache short-
|
|
circuits before any rebuild decision, hiding the prefer_cache contract.
|
|
"""
|
|
monkeypatch.setattr(cfg, "_available_models_cache", None, raising=False)
|
|
monkeypatch.setattr(cfg, "_available_models_cache_ts", 0.0, raising=False)
|
|
monkeypatch.setattr(
|
|
cfg, "_available_models_cache_source_fingerprint", None, raising=False
|
|
)
|
|
monkeypatch.setattr(cfg, "_cache_build_in_progress", False, raising=False)
|
|
# Never read/write the real on-disk cache during the test.
|
|
monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None)
|
|
monkeypatch.setattr(cfg, "_save_models_cache_to_disk", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(cfg, "_delete_models_cache_on_disk", lambda: None)
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def rebuild_seam_tripwire(monkeypatch):
|
|
"""Make the live provider-catalog rebuild seam fail loudly if reached.
|
|
|
|
``_invoke_models_rebuild`` is the documented indirection seam around the
|
|
cold, network-touching per-provider rebuild. The display resolvers must
|
|
never reach it (prefer_cache returns the network-free minimal catalog
|
|
*before* this seam). If a future edit drops ``prefer_cached_catalog=True``,
|
|
the resolver falls into the cold rebuild and trips this wire.
|
|
"""
|
|
calls = {"n": 0}
|
|
|
|
def _boom(_builder):
|
|
calls["n"] += 1
|
|
raise AssertionError(
|
|
"live provider-catalog rebuild ran on the hot GET /api/session "
|
|
"display path — prefer_cached_catalog regression"
|
|
)
|
|
|
|
monkeypatch.setattr(cfg, "_invoke_models_rebuild", _boom)
|
|
return calls
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model_provider",
|
|
[None, "", "anthropic"],
|
|
ids=["no-provider", "empty-provider", "with-provider"],
|
|
)
|
|
def test_session_display_resolvers_never_trigger_live_rebuild(
|
|
cold_models_cache, rebuild_seam_tripwire, model_provider
|
|
):
|
|
session = _FakeSession("claude-opus-4-7", model_provider)
|
|
|
|
# Must not raise (the tripwire raises AssertionError if the live rebuild
|
|
# path is entered) and must return the persisted model verbatim.
|
|
model = routes._resolve_effective_session_model_for_display(session)
|
|
provider = routes._resolve_effective_session_model_provider_for_display(session)
|
|
|
|
assert model == "claude-opus-4-7"
|
|
# provider is best-effort; the contract under test is "no live rebuild",
|
|
# not a specific provider string. It must at least be None or a str.
|
|
assert provider is None or isinstance(provider, str)
|
|
assert rebuild_seam_tripwire["n"] == 0
|
|
|
|
|
|
def _has_prefer_cached_catalog_true_call(fn) -> bool:
|
|
tree = ast.parse(inspect.getsource(fn))
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
if not isinstance(node.func, ast.Name):
|
|
continue
|
|
if node.func.id != "_resolve_compatible_session_model_state":
|
|
continue
|
|
for keyword in node.keywords:
|
|
if keyword.arg == "prefer_cached_catalog" and isinstance(
|
|
keyword.value, ast.Constant
|
|
):
|
|
return keyword.value.value is True
|
|
return False
|
|
|
|
|
|
def test_resolver_signature_passes_prefer_cached_catalog():
|
|
"""Static guard: both resolvers must opt into the cache-only catalog.
|
|
|
|
A pure behavioural test can be satisfied by an unrelated short-circuit;
|
|
this pins the explicit contract at the call site so the intent survives
|
|
refactors.
|
|
"""
|
|
assert _has_prefer_cached_catalog_true_call(
|
|
routes._resolve_effective_session_model_for_display
|
|
)
|
|
assert _has_prefer_cached_catalog_true_call(
|
|
routes._resolve_effective_session_model_provider_for_display
|
|
)
|