From 0378ed4e34dbb30ef2efd733f5dee0a3f4641aea Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 09:57:26 +0000 Subject: [PATCH] fix(routes): preserve live cards on cross-profile 409 collisions Gate follow-up for #7710. Three front-end handlers treated EVERY 409 as a domain-specific stale/expired signal, so the new session_profile_mismatch response would have: - messages.js: hidden a LIVE clarify card and mislabelled it expired - ui.js: retired a LIVE compression-recovery card with a false note - commands.js: rendered a false 'Compression failed' error state Each now routes through _sessionProfileMismatchFromError() first and leaves the card standing. Also captures the status in the import_cli test mock so a wrong status can no longer pass (the mock swallowed it into *_). --- static/commands.js | 6 ++++++ static/messages.js | 13 +++++++++++++ static/ui.js | 10 ++++++++++ ...test_issue4067_import_cli_cross_profile_guard.py | 13 +++++++++---- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/static/commands.js b/static/commands.js index fa787e24..da741566 100644 --- a/static/commands.js +++ b/static/commands.js @@ -991,6 +991,12 @@ async function resumeManualCompressionForSession(sid){ // No active compression job or transient server error — not a real failure. // 404: route missed or session gone; 5xx: backend exception during status check. if(e&&(!e.status||e.status===404||e.status>=500)) return; + // #7710: a cross-profile refusal now arrives as 409 + // (``session_profile_mismatch``) where it used to be a 404 that hit the + // benign early-return above. It is not a compression failure, so do not + // render the error state or locally settle the compression UI. + if(e&&e.status===409&&typeof _sessionProfileMismatchFromError==='function' + &&_sessionProfileMismatchFromError(e)) return; if(S.session&&S.session.session_id===sid&&typeof setCompressionUi==='function'){ const visibleMessages=_manualCompressionVisibleMessages(); setCompressionUi({ diff --git a/static/messages.js b/static/messages.js index e7bcb73c..078e6035 100644 --- a/static/messages.js +++ b/static/messages.js @@ -8988,6 +8988,19 @@ async function respondClarify(response) { // not tear B down on A's late 409. The SSE/poll path will re-render the // next prompt's card from scratch via ``showClarifyCard`` either way. if (e && e.status === 409) { + // #7710: a cross-profile refusal now also arrives as 409 + // (``session_profile_mismatch``). The prompt is NOT expired — the write + // was refused because the session belongs to another profile. Treating + // it as expired would hide a live clarification card and mislabel the + // cause, so leave the card standing and report the real reason. + if (typeof _sessionProfileMismatchFromError === 'function' + && _sessionProfileMismatchFromError(e)) { + _clarifySetControlsDisabled(false, false); + if (typeof setStatus === "function") { + setStatus("Clarify: session belongs to a different profile"); + } + return; + } if (_clarifyId === clarifyId) { // Same card still showing — dismiss it and rescue the typed draft. // Order matters: ``_stashClarifyDraft`` (called from diff --git a/static/ui.js b/static/ui.js index 37742c5a..675bd66d 100644 --- a/static/ui.js +++ b/static/ui.js @@ -508,6 +508,16 @@ async function startCompressionRecovery(btn){ const composer=$('msg'); if(composer&&typeof composer.focus==='function') composer.focus(); }catch(e){ + // #7710: a cross-profile refusal now also arrives as 409 + // (``session_profile_mismatch``). That is NOT a stale recovery action — + // the card is still valid, the request was simply refused because the + // session belongs to another profile. Retiring it would hide a live card + // and show a false "conversation already moved on" note. + if(e&&e.status===409&&typeof _sessionProfileMismatchFromError==='function' + &&_sessionProfileMismatchFromError(e)){ + if(typeof setStatus==='function') setStatus('Session belongs to a different profile'); + return; + } // A 409 means this session no longer has an active recovery action (the // session already moved on — e.g. a substantive prompt cleared it). The // persisted card in the transcript is stale, so retire it and show a neutral diff --git a/tests/test_issue4067_import_cli_cross_profile_guard.py b/tests/test_issue4067_import_cli_cross_profile_guard.py index d216b060..42274c32 100644 --- a/tests/test_issue4067_import_cli_cross_profile_guard.py +++ b/tests/test_issue4067_import_cli_cross_profile_guard.py @@ -44,10 +44,14 @@ def _capture(monkeypatch): # #7710: the real ``j`` signature is ``j(handler, payload, status=200, # extra_headers=None, *, pretty=True)``; the mock has to accept the # status keyword so the helper's 409 ``session_profile_mismatch`` - # payload can be captured here. - monkeypatch.setattr( - routes, "j", lambda h, o, *_, **__: (cap.__setitem__("ok", o), True)[1] - ) + # payload can be captured here. Capture the STATUS too — swallowing it + # into ``*_`` would let a wrong status (200/404) pass this test. + def _fake_j(h, o, status=200, *_, **__): + cap["ok"] = o + cap["ok_status"] = status + return True + + monkeypatch.setattr(routes, "j", _fake_j) monkeypatch.setattr( routes, "bad", @@ -98,6 +102,7 @@ def test_import_cli_existing_foreign_profile_unqualified_request_404(monkeypatch ) assert "ok" in cap, f"expected 409 session_profile_mismatch, got {cap}" + assert cap["ok_status"] == 409, f"expected status 409, got {cap.get('ok_status')}" assert cap["ok"] == { "error": "Session belongs to a different profile", "code": "session_profile_mismatch",