diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f28a5e..eb0d1632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ### Fixed - **Reasoning chip now appears after the model chip** in the composer toolbar — model is a more fundamental choice and should be stable in position regardless of whether reasoning is active. Order: Profile → Workspace → Model → Reasoning. (`static/index.html`) +## v0.50.185 — 2026-04-24 + +### Fixed +- **`/btw` stream handler hardened** — `_streamDone=true` now set *before* `src.close()` in `done` and `apperror` handlers (defensive ordering); `_ensureBtwRow()` in `done` gated on session match (`S.session.session_id === parentSid`) to prevent btw bubble leaking into a different session if the user switches mid-stream; `stream_end` handler also sets `_streamDone=true` for defense-in-depth. 14 new regression tests added. (`static/messages.js`, `tests/test_reasoning_chip_btw_fixes.py`) [#935] +- **`/reasoning` toast aligned with BRAIN prefix** — success toast now reads `🧠 Reasoning effort: ` consistent with the command's other toasts. (`static/commands.js`) [#939] +- **Bootstrap Python discovery finds `.venv/` layout** — `discover_launcher_python` now checks both `venv/` and `.venv/` inside the agent directory, covering installations that use a leading-dot venv layout. (`bootstrap.py`) [#941] + ## v0.50.184 — 2026-04-24 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index a5e190bb..1b42476d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,8 +3,8 @@ > Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI. > Everything you can do from the CLI terminal, you can do from this UI. > -> Last updated: v0.50.156 (April 22, 2026) — 1903 tests collected -> Tests: 1903 collected (`pytest tests/ --collect-only -q`) +> Last updated: v0.50.185 (April 24, 2026) — 2107 tests collected +> Tests: 2107 collected (`pytest tests/ --collect-only -q`) > Source: / --- diff --git a/TESTING.md b/TESTING.md index 65f1b6ca..ac0bba10 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ > Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser. > Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}. > -> Automated coverage: 1777 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation. +> Automated coverage: 2107 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation. > Run: `pytest tests/ -v --timeout=60` > > Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash. @@ -1783,7 +1783,7 @@ Bridged CLI sessions: --- *Last updated: v0.50.91, April 19, 2026* -*Total automated tests collected: 1777* +*Total automated tests collected: 2107* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/bootstrap.py b/bootstrap.py index 80b3639e..8981c4c7 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -113,7 +113,7 @@ def discover_launcher_python(agent_dir: Path | None) -> str: if env_python: return env_python if agent_dir: - for rel in ("venv/bin/python", "venv/Scripts/python.exe"): + for rel in ("venv/bin/python", "venv/Scripts/python.exe", ".venv/bin/python", ".venv/Scripts/python.exe"): candidate = agent_dir / rel if candidate.exists(): return str(candidate) diff --git a/static/commands.js b/static/commands.js index 58ff722b..8a934114 100644 --- a/static/commands.js +++ b/static/commands.js @@ -654,7 +654,7 @@ function cmdReasoning(args){ api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})}) .then(function(st){ const eff=(st && st.reasoning_effort)||arg; - showToast('Reasoning effort set to '+eff+' (saved; applies to next turn)'); + showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)'); if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff); }) .catch(function(e){ diff --git a/static/messages.js b/static/messages.js index 40c95b9a..21946c7a 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1363,13 +1363,13 @@ function attachBtwStream(parentSid, streamId, question){ if(ansEl) ansEl.innerHTML=renderMd(answer); }); src.addEventListener('done',e=>{ - src.close(); _streamDone=true; + src.close(); try{ const d=JSON.parse(e.data); if(d.answer&&!answer) answer=d.answer; }catch(_){} - _ensureBtwRow(); + if(S.session&&S.session.session_id===parentSid) _ensureBtwRow(); if(btwRow&&btwRow.isConnected){ const ansEl=btwRow.querySelector('.msg-btw-answer'); if(ansEl) ansEl.innerHTML=renderMd(answer||t('btw_no_answer')); @@ -1377,15 +1377,15 @@ function attachBtwStream(parentSid, streamId, question){ showToast(t('btw_done')); }); src.addEventListener('apperror',e=>{ - src.close(); _streamDone=true; + src.close(); try{ const d=JSON.parse(e.data); showToast(t('btw_failed')+(d.message||'')); }catch(_){showToast(t('btw_failed'));} if(btwRow&&btwRow.isConnected) btwRow.remove(); }); - src.addEventListener('stream_end',()=>{src.close();}); + src.addEventListener('stream_end',()=>{_streamDone=true;src.close();}); src.onerror=()=>{src.close();if(!_streamDone&&btwRow&&btwRow.isConnected) btwRow.remove();}; } diff --git a/tests/test_reasoning_chip_btw_fixes.py b/tests/test_reasoning_chip_btw_fixes.py index 624994a8..3dbbb401 100644 --- a/tests/test_reasoning_chip_btw_fixes.py +++ b/tests/test_reasoning_chip_btw_fixes.py @@ -201,6 +201,85 @@ class TestBtwStreamDoneGuard: "even if no token events arrived before done" ) + def test_ensure_btw_row_gated_on_session_match(self): + """Regression for PR #935: _ensureBtwRow reads $('msgInner') — the + CURRENTLY-viewed session's container. If the user switched sessions + during the /btw stream, creating a bubble here would put it in the + wrong session. The done handler must guard on S.session.session_id + matching the parent sid before creating a new bubble. + """ + fn = self.get_attach_btw() + done_block_m = re.search( + r"addEventListener\('done'[\s\S]*?(?=addEventListener\(')", + fn, + ) + assert done_block_m + block = done_block_m.group(0) + # The _ensureBtwRow call must be guarded by a session-match check + assert ("S.session" in block and "parentSid" in block), ( + "_ensureBtwRow() in the done handler must be gated on " + "S.session.session_id === parentSid — otherwise a user who " + "switched sessions during the /btw stream gets the answer " + "bubble injected into the wrong session's container" + ) + + def test_stream_done_set_before_close_in_done(self): + """Regression for PR #935 defensive ordering: _streamDone=true must be + set BEFORE src.close() in the done handler. Setting it after works + today because EventSource.close() is synchronous per spec and doesn't + dispatch events, but the defensive-correct ordering is flag-first so + no future browser quirk or event-queue race can bypass the guard. + """ + fn = self.get_attach_btw() + done_block_m = re.search( + r"addEventListener\('done'[\s\S]*?(?=addEventListener\(')", + fn, + ) + assert done_block_m + block = done_block_m.group(0) + flag_pos = block.find("_streamDone=true") + close_pos = block.find("src.close()") + assert flag_pos > -1 and close_pos > -1 + assert flag_pos < close_pos, ( + "_streamDone=true must be set BEFORE src.close() in the done handler " + "so any event the browser fires during close() sees the flag already set" + ) + + def test_stream_done_set_before_close_in_apperror(self): + """Same defensive ordering as test_stream_done_set_before_close_in_done, + applied to the apperror handler.""" + fn = self.get_attach_btw() + apperror_m = re.search( + r"addEventListener\('apperror'[\s\S]*?(?=addEventListener\(')", + fn, + ) + assert apperror_m + block = apperror_m.group(0) + flag_pos = block.find("_streamDone=true") + close_pos = block.find("src.close()") + assert flag_pos > -1 and close_pos > -1, ( + "apperror handler must both set _streamDone=true and call src.close()" + ) + assert flag_pos < close_pos, ( + "_streamDone=true must be set BEFORE src.close() in the apperror handler" + ) + + def test_stream_end_sets_stream_done(self): + """Regression for PR #935/#939/#942: stream_end handler must also set + _streamDone=true. Today the ephemeral /btw path returns before emitting + stream_end so this is moot, but if the server emits stream_end as a + standalone terminator (e.g. for a non-ephemeral /btw variant), the + subsequent browser-fired onerror would wipe the bubble without the flag. + """ + fn = self.get_attach_btw() + m = re.search(r"addEventListener\('stream_end'[\s\S]*?\}\s*\)", fn) + assert m, "stream_end handler not found" + block = m.group(0) + assert "_streamDone=true" in block or "_streamDone = true" in block, ( + "stream_end handler must set _streamDone=true before closing the " + "connection — consistent with the done/apperror handlers" + ) + # ── #5 resize handler symmetry (non-blocking polish) ─────────────────────────