Closes #631. Closes #804. Bug A (thinking card below answer / double render / stuck cursor): trailing rAF after 'done' inserted a duplicate live-turn wrapper into already-settled DOM. Fixed via _streamFinalized flag + cancelAnimationFrame in all terminal handlers (done/apperror/cancel/_handleStreamError) + _scheduleRender guard. All three reported symptoms were the same root cause. Bug B (accumulator reset): original fix reset assistantText/reasoningText inside _wireSSE on reconnect. Reverted — server uses one-shot queue.Queue(), no replay on reconnect, reset would wipe valid pre-drop content causing data loss. Bug A fix alone resolves all symptoms. #804 (blank page workspace): syncWorkspaceDisplays uses S._profileDefaultWorkspace as fallback; workspace chip enabled when hasWorkspace (not hasSession); promptNewFile/promptNewFolder/ switchToWorkspace/promptWorkspacePath auto-create session on blank page; boot.js hydrates _profileDefaultWorkspace from /api/settings before any session exists. Opus max-effort review + Nathan independent review + full browser QA. 1765/1765 tests.
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# Hermes Web UI -- Changelog
|
||||||
|
|
||||||
|
## [v0.50.138] — 2026-04-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Streaming: response no longer renders twice or leaves thinking block below the answer** — two race conditions in `attachLiveStream` fixed. (A) A trailing `token`/`reasoning` event could queue a `requestAnimationFrame` that fired after `done` had already called `renderMessages()`, inserting a duplicate live-turn wrapper below the settled response. Fixed via `_streamFinalized` flag + `cancelAnimationFrame` in all terminal handlers (`done`, `apperror`, `cancel`, `_handleStreamError`). (B) A proposed accumulator-reset on SSE reconnect was reverted — the server uses a one-shot queue and does not replay events; the reset would have wiped pre-drop response content. Bug A's fix alone resolves all three reported symptoms (double render, thinking card below answer, stuck cursor). (#821, closes #631)
|
||||||
|
- **Blank new-chat page now shows default workspace and allows workspace actions** — `syncWorkspaceDisplays()` uses `S._profileDefaultWorkspace` as fallback when no session is active; the workspace chip is now enabled on the blank page; `promptNewFile`, `promptNewFolder`, `switchToWorkspace`, and `promptWorkspacePath` all auto-create a session bound to the default workspace when called on the blank page, rather than silently returning. Boot.js hydrates `S._profileDefaultWorkspace` from `/api/settings.default_workspace` before any session is created. (#821, closes #804)
|
||||||
|
|
||||||
## [v0.50.135] — 2026-04-22
|
## [v0.50.135] — 2026-04-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -767,6 +767,9 @@ function applyBotName(){
|
|||||||
window._showThinking=s.show_thinking!==false;
|
window._showThinking=s.show_thinking!==false;
|
||||||
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
||||||
window._botName=s.bot_name||'Hermes';
|
window._botName=s.bot_name||'Hermes';
|
||||||
|
// Persist default workspace so the blank new-chat page can show it
|
||||||
|
// and workspace actions (New file/folder) work before the first session (#804).
|
||||||
|
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
|
||||||
const appearance=_normalizeAppearance(s.theme,s.skin);
|
const appearance=_normalizeAppearance(s.theme,s.skin);
|
||||||
localStorage.setItem('hermes-theme',appearance.theme);
|
localStorage.setItem('hermes-theme',appearance.theme);
|
||||||
_applyTheme(appearance.theme);
|
_applyTheme(appearance.theme);
|
||||||
|
|||||||
@@ -231,6 +231,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
let _reconnectAttempted=false;
|
let _reconnectAttempted=false;
|
||||||
let _terminalStateReached=false;
|
let _terminalStateReached=false;
|
||||||
|
|
||||||
|
// Bug A fix (#631): track whether the stream has been finalized so any rAF
|
||||||
|
// scheduled by a trailing 'token'/'reasoning' event that arrives in the same
|
||||||
|
// microtask batch as 'done' does not fire after renderMessages() has already
|
||||||
|
// settled the DOM — which was causing the thinking card to reappear below
|
||||||
|
// the final answer or the response to render twice.
|
||||||
|
let _streamFinalized=false;
|
||||||
|
let _pendingRafHandle=null;
|
||||||
|
|
||||||
// rAF-throttled rendering: buffer tokens, render at most once per frame
|
// rAF-throttled rendering: buffer tokens, render at most once per frame
|
||||||
let _renderPending=false;
|
let _renderPending=false;
|
||||||
// Extract display text from assistantText, stripping completed thinking blocks
|
// Extract display text from assistantText, stripping completed thinking blocks
|
||||||
@@ -306,8 +314,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
}
|
}
|
||||||
function _scheduleRender(){
|
function _scheduleRender(){
|
||||||
if(_renderPending) return;
|
if(_renderPending) return;
|
||||||
|
if(_streamFinalized) return; // Bug A: don't schedule new rAF after stream finalized
|
||||||
_renderPending=true;
|
_renderPending=true;
|
||||||
requestAnimationFrame(()=>{
|
_pendingRafHandle=requestAnimationFrame(()=>{
|
||||||
|
_pendingRafHandle=null;
|
||||||
_renderPending=false;
|
_renderPending=false;
|
||||||
const parsed=_parseStreamState();
|
const parsed=_parseStreamState();
|
||||||
_renderLiveThinking(parsed);
|
_renderLiveThinking(parsed);
|
||||||
@@ -319,6 +329,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _wireSSE(source){
|
function _wireSSE(source){
|
||||||
|
// Note on #631 Bug B: the original PR description stated the server
|
||||||
|
// "replays buffered token events" on reconnect, and proposed resetting
|
||||||
|
// the accumulators here so the re-sent tokens wouldn't double the prefix.
|
||||||
|
// That is NOT how the server actually works — api/routes._handle_sse_stream
|
||||||
|
// reads a one-shot queue.Queue() that delivers each event to exactly one
|
||||||
|
// consumer; a reconnect picks up from the current queue position and gets
|
||||||
|
// only events produced during the outage. Resetting the accumulators here
|
||||||
|
// would wipe the already-displayed content and restart the response from
|
||||||
|
// the first post-reconnect token — a real data-loss regression.
|
||||||
|
//
|
||||||
|
// The "doubled response" / "stuck cursor" symptom is fully explained by
|
||||||
|
// Bug A (trailing rAF after `done` inserting a new live-turn wrapper) —
|
||||||
|
// the fixes below (_streamFinalized guard + cancelAnimationFrame in the
|
||||||
|
// terminal handlers) address it without needing a reset here.
|
||||||
|
|
||||||
source.addEventListener('token',e=>{
|
source.addEventListener('token',e=>{
|
||||||
if(!S.session||S.session.session_id!==activeSid) return;
|
if(!S.session||S.session.session_id!==activeSid) return;
|
||||||
const d=JSON.parse(e.data);
|
const d=JSON.parse(e.data);
|
||||||
@@ -446,6 +471,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
|
|
||||||
source.addEventListener('done',e=>{
|
source.addEventListener('done',e=>{
|
||||||
_terminalStateReached=true;
|
_terminalStateReached=true;
|
||||||
|
// Bug A fix: cancel any pending rAF and mark stream finalized before
|
||||||
|
// the DOM is settled by renderMessages, so no trailing token/reasoning rAF
|
||||||
|
// can reintroduce a stale thinking card or duplicate content.
|
||||||
|
_streamFinalized=true;
|
||||||
|
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
|
||||||
|
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||||
const d=JSON.parse(e.data);
|
const d=JSON.parse(e.data);
|
||||||
delete INFLIGHT[activeSid];
|
delete INFLIGHT[activeSid];
|
||||||
clearInflight();clearInflightState(activeSid);
|
clearInflight();clearInflightState(activeSid);
|
||||||
@@ -509,6 +540,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
|
|
||||||
source.addEventListener('apperror',e=>{
|
source.addEventListener('apperror',e=>{
|
||||||
_terminalStateReached=true;
|
_terminalStateReached=true;
|
||||||
|
_streamFinalized=true;
|
||||||
|
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
|
||||||
|
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||||
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
|
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
|
||||||
// This is distinct from the SSE network 'error' event below.
|
// This is distinct from the SSE network 'error' event below.
|
||||||
source.close();
|
source.close();
|
||||||
@@ -553,7 +587,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
|
|
||||||
source.addEventListener('error',async e=>{
|
source.addEventListener('error',async e=>{
|
||||||
source.close();
|
source.close();
|
||||||
if(_terminalStateReached){
|
if(_terminalStateReached || _streamFinalized){
|
||||||
_closeSource();
|
_closeSource();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -581,6 +615,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
|
|
||||||
source.addEventListener('cancel',e=>{
|
source.addEventListener('cancel',e=>{
|
||||||
_terminalStateReached=true;
|
_terminalStateReached=true;
|
||||||
|
_streamFinalized=true;
|
||||||
|
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
|
||||||
|
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||||
source.close();
|
source.close();
|
||||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||||
@@ -632,6 +669,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _handleStreamError(){
|
function _handleStreamError(){
|
||||||
|
// Opus review Q1: mirror done/apperror/cancel finalization so any pending rAF
|
||||||
|
// cannot fire after renderMessages() has settled the DOM with the error message.
|
||||||
|
_streamFinalized=true;
|
||||||
|
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
|
||||||
|
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||||
_closeSource();
|
_closeSource();
|
||||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||||
|
|||||||
@@ -537,8 +537,12 @@ function getWorkspaceFriendlyName(path){
|
|||||||
|
|
||||||
function syncWorkspaceDisplays(){
|
function syncWorkspaceDisplays(){
|
||||||
const hasSession=!!(S.session&&S.session.workspace);
|
const hasSession=!!(S.session&&S.session.workspace);
|
||||||
const ws=hasSession?S.session.workspace:'';
|
// Fall back to the profile default workspace when no session is active yet.
|
||||||
const label=hasSession?getWorkspaceFriendlyName(ws):t('no_workspace');
|
// S._profileDefaultWorkspace is set during boot and profile switches from /api/settings.
|
||||||
|
const defaultWs=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
|
||||||
|
const ws=hasSession?S.session.workspace:(defaultWs||'');
|
||||||
|
const hasWorkspace=!!(ws);
|
||||||
|
const label=hasWorkspace?getWorkspaceFriendlyName(ws):t('no_workspace');
|
||||||
|
|
||||||
const sidebarName=$('sidebarWsName');
|
const sidebarName=$('sidebarWsName');
|
||||||
const sidebarPath=$('sidebarWsPath');
|
const sidebarPath=$('sidebarWsPath');
|
||||||
@@ -548,13 +552,13 @@ function syncWorkspaceDisplays(){
|
|||||||
const composerChip=$('composerWorkspaceChip');
|
const composerChip=$('composerWorkspaceChip');
|
||||||
const composerLabel=$('composerWorkspaceLabel');
|
const composerLabel=$('composerWorkspaceLabel');
|
||||||
const composerDropdown=$('composerWsDropdown');
|
const composerDropdown=$('composerWsDropdown');
|
||||||
if(!hasSession && composerDropdown) composerDropdown.classList.remove('open');
|
if(!hasWorkspace && composerDropdown) composerDropdown.classList.remove('open');
|
||||||
// Only show workspace label once boot has finished to prevent
|
// Only show workspace label once boot has finished to prevent
|
||||||
// flash of "No workspace" before the saved session finishes loading.
|
// flash of "No workspace" before the saved session finishes loading.
|
||||||
if(composerLabel) composerLabel.textContent=S._bootReady?label:'';
|
if(composerLabel) composerLabel.textContent=S._bootReady?label:'';
|
||||||
if(composerChip){
|
if(composerChip){
|
||||||
composerChip.disabled=!hasSession;
|
composerChip.disabled=!hasWorkspace;
|
||||||
composerChip.title=hasSession?ws:t('no_workspace');
|
composerChip.title=hasWorkspace?ws:t('no_workspace');
|
||||||
composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
|
composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -738,7 +742,16 @@ async function removeWorkspace(path){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function promptWorkspacePath(){
|
async function promptWorkspacePath(){
|
||||||
if(!S.session)return;
|
// Opus review Q6: if called from blank page (no session), auto-create one first.
|
||||||
|
if(!S.session){
|
||||||
|
const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
|
||||||
|
if(!ws)return;
|
||||||
|
try{
|
||||||
|
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
|
||||||
|
if(r&&r.session){S.session=r.session;S.messages=[];if(typeof syncTopbar==='function')syncTopbar();if(typeof renderMessages==='function')renderMessages();if(typeof renderSessionList==='function')await renderSessionList();}
|
||||||
|
}catch(e){showToast(t('workspace_switch_failed')+e.message);return;}
|
||||||
|
if(!S.session)return;
|
||||||
|
}
|
||||||
const value=await showPromptDialog({
|
const value=await showPromptDialog({
|
||||||
title:t('workspace_switch_prompt_title'),
|
title:t('workspace_switch_prompt_title'),
|
||||||
message:t('workspace_switch_prompt_message'),
|
message:t('workspace_switch_prompt_message'),
|
||||||
@@ -764,7 +777,17 @@ async function promptWorkspacePath(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function switchToWorkspace(path,name){
|
async function switchToWorkspace(path,name){
|
||||||
if(!S.session)return;
|
// Opus review Q6: if called from blank page, auto-create a session bound to
|
||||||
|
// the requested workspace so the switch doesn't silently no-op.
|
||||||
|
if(!S.session){
|
||||||
|
const ws=path||(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
|
||||||
|
if(!ws){showToast(t('no_workspace'));return;}
|
||||||
|
try{
|
||||||
|
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
|
||||||
|
if(r&&r.session){S.session=r.session;S.messages=[];if(typeof syncTopbar==='function')syncTopbar();if(typeof renderMessages==='function')renderMessages();if(typeof renderSessionList==='function')await renderSessionList();}
|
||||||
|
}catch(e){if(typeof setStatus==='function')setStatus(t('switch_failed')+e.message);return;}
|
||||||
|
if(!S.session)return;
|
||||||
|
}
|
||||||
if(S.busy){
|
if(S.busy){
|
||||||
showToast(t('workspace_busy_switch'));
|
showToast(t('workspace_busy_switch'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
19
static/ui.js
19
static/ui.js
@@ -2417,6 +2417,16 @@ async function deleteWorkspaceFile(relPath, name){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function promptNewFile(){
|
async function promptNewFile(){
|
||||||
|
// If no active session but a default workspace is configured, auto-create
|
||||||
|
// a session bound to it so workspace actions work on the blank new-chat page.
|
||||||
|
if(!S.session){
|
||||||
|
const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
|
||||||
|
if(!ws) return;
|
||||||
|
try{
|
||||||
|
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
|
||||||
|
if(r&&r.session){S.session=r.session;S.messages=[];syncTopbar();renderMessages();await renderSessionList();}
|
||||||
|
}catch(e){setStatus(t('create_failed')+e.message);return;}
|
||||||
|
}
|
||||||
if(!S.session)return;
|
if(!S.session)return;
|
||||||
const name=await showPromptDialog({title:t('new_file_prompt'),placeholder:'filename.txt',confirmLabel:t('create')});
|
const name=await showPromptDialog({title:t('new_file_prompt'),placeholder:'filename.txt',confirmLabel:t('create')});
|
||||||
if(!name||!name.trim())return;
|
if(!name||!name.trim())return;
|
||||||
@@ -2430,6 +2440,15 @@ async function promptNewFile(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function promptNewFolder(){
|
async function promptNewFolder(){
|
||||||
|
// Same auto-create-session logic as promptNewFile for the blank page.
|
||||||
|
if(!S.session){
|
||||||
|
const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
|
||||||
|
if(!ws) return;
|
||||||
|
try{
|
||||||
|
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
|
||||||
|
if(r&&r.session){S.session=r.session;S.messages=[];syncTopbar();renderMessages();await renderSessionList();}
|
||||||
|
}catch(e){setStatus(t('folder_create_failed')+e.message);return;}
|
||||||
|
}
|
||||||
if(!S.session)return;
|
if(!S.session)return;
|
||||||
const name=await showPromptDialog({title:t('new_folder_prompt'),placeholder:'folder-name',confirmLabel:t('create')});
|
const name=await showPromptDialog({title:t('new_folder_prompt'),placeholder:'folder-name',confirmLabel:t('create')});
|
||||||
if(!name||!name.trim())return;
|
if(!name||!name.trim())return;
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ def test_done_handler_sets_busy_false_before_renderMessages(cleanup_test_session
|
|||||||
if done_idx < 0:
|
if done_idx < 0:
|
||||||
done_idx = src.find("es.addEventListener('done'")
|
done_idx = src.find("es.addEventListener('done'")
|
||||||
assert done_idx >= 0
|
assert done_idx >= 0
|
||||||
done_block = src[done_idx:done_idx+2500]
|
done_block = src[done_idx:done_idx+2900]
|
||||||
# S.busy=false must appear before renderMessages() within the done handler
|
# S.busy=false must appear before renderMessages() within the done handler
|
||||||
busy_pos = done_block.find("S.busy=false;")
|
busy_pos = done_block.find("S.busy=false;")
|
||||||
render_pos = done_block.find("renderMessages()")
|
render_pos = done_block.find("renderMessages()")
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ def test_sse_cancel_handler_calls_set_busy():
|
|||||||
if idx == -1:
|
if idx == -1:
|
||||||
idx = src.find('addEventListener("cancel"')
|
idx = src.find('addEventListener("cancel"')
|
||||||
assert idx != -1
|
assert idx != -1
|
||||||
block = src[idx:idx + 1000]
|
block = src[idx:idx + 1200]
|
||||||
assert "setBusy(false)" in block, (
|
assert "setBusy(false)" in block, (
|
||||||
"SSE cancel handler no longer calls setBusy(false)"
|
"SSE cancel handler no longer calls setBusy(false)"
|
||||||
)
|
)
|
||||||
|
|||||||
175
tests/test_streaming_race_fix.py
Normal file
175
tests/test_streaming_race_fix.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Tests for #631 — streaming race conditions in messages.js
|
||||||
|
|
||||||
|
Bug A: A trailing 'token'/'reasoning' event queued a requestAnimationFrame that
|
||||||
|
fired after 'done' had already called renderMessages(), causing the thinking card
|
||||||
|
to reappear below the final answer or the response to render twice.
|
||||||
|
|
||||||
|
Bug B: On SSE reconnect, the closure variables (assistantText, reasoningText)
|
||||||
|
were not reset. Server replays token events into the new EventSource, causing
|
||||||
|
text to accumulate again from the stale values — response doubled, stuck cursor.
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
- _streamFinalized flag + _pendingRafHandle stored for cancellation
|
||||||
|
- done/apperror/cancel: set _streamFinalized, cancel pending rAF, call finalizeThinkingCard
|
||||||
|
- _scheduleRender: guard on _streamFinalized
|
||||||
|
- _wireSSE: reset accumulators when (re)opening source, unless stream already finalized
|
||||||
|
- error handler: bail if _streamFinalized (same as _terminalStateReached)
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
REPO = pathlib.Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def read(rel):
|
||||||
|
return (REPO / rel).read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamFinalized:
|
||||||
|
"""_streamFinalized flag and rAF cancellation."""
|
||||||
|
|
||||||
|
def test_stream_finalized_declared(self):
|
||||||
|
src = read('static/messages.js')
|
||||||
|
assert '_streamFinalized' in src, (
|
||||||
|
"_streamFinalized must be declared in attachLiveStream"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pending_raf_handle_declared(self):
|
||||||
|
src = read('static/messages.js')
|
||||||
|
assert '_pendingRafHandle' in src, (
|
||||||
|
"_pendingRafHandle must be declared to enable rAF cancellation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schedule_render_guards_on_stream_finalized(self):
|
||||||
|
src = read('static/messages.js')
|
||||||
|
m = re.search(r'function _scheduleRender\(\)\{.*?\n \}', src, re.DOTALL)
|
||||||
|
assert m, "_scheduleRender not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert '_streamFinalized' in fn, (
|
||||||
|
"_scheduleRender must return early when _streamFinalized is true"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_raf_handle_stored_in_schedule_render(self):
|
||||||
|
src = read('static/messages.js')
|
||||||
|
assert '_pendingRafHandle=requestAnimationFrame' in src or \
|
||||||
|
'_pendingRafHandle = requestAnimationFrame' in src, (
|
||||||
|
"rAF handle must be stored in _pendingRafHandle for cancellation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_done_sets_stream_finalized(self):
|
||||||
|
src = read('static/messages.js')
|
||||||
|
m = re.search(r"source\.addEventListener\('done'.*?\}\);", src, re.DOTALL)
|
||||||
|
assert m, "'done' handler not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert '_streamFinalized=true' in fn or '_streamFinalized = true' in fn, (
|
||||||
|
"'done' handler must set _streamFinalized=true"
|
||||||
|
)
|
||||||
|
assert 'cancelAnimationFrame' in fn, (
|
||||||
|
"'done' handler must cancel any pending rAF"
|
||||||
|
)
|
||||||
|
assert 'finalizeThinkingCard' in fn, (
|
||||||
|
"'done' handler must call finalizeThinkingCard() to close thinking card"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apperror_sets_stream_finalized(self):
|
||||||
|
src = read('static/messages.js')
|
||||||
|
m = re.search(r"source\.addEventListener\('apperror'.*?\}\);", src, re.DOTALL)
|
||||||
|
assert m, "'apperror' handler not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert '_streamFinalized=true' in fn or '_streamFinalized = true' in fn, (
|
||||||
|
"'apperror' handler must set _streamFinalized=true"
|
||||||
|
)
|
||||||
|
assert 'cancelAnimationFrame' in fn
|
||||||
|
|
||||||
|
def test_cancel_sets_stream_finalized(self):
|
||||||
|
src = read('static/messages.js')
|
||||||
|
m = re.search(r"source\.addEventListener\('cancel'.*?\}\);", src, re.DOTALL)
|
||||||
|
assert m, "'cancel' handler not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert '_streamFinalized=true' in fn or '_streamFinalized = true' in fn, (
|
||||||
|
"'cancel' handler must set _streamFinalized=true"
|
||||||
|
)
|
||||||
|
assert 'cancelAnimationFrame' in fn
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconnectAccumulatorPreservation:
|
||||||
|
"""Bug B regression guard: the accumulators must NOT be reset on reconnect.
|
||||||
|
|
||||||
|
The original PR description claimed the server "replays buffered token
|
||||||
|
events" on SSE reconnect, and proposed resetting `assistantText` /
|
||||||
|
`reasoningText` inside `_wireSSE` to absorb that replay. That is not
|
||||||
|
how the server actually works — `api/routes._handle_sse_stream` reads
|
||||||
|
a one-shot `queue.Queue()` that delivers each event to exactly one
|
||||||
|
consumer. When a client reconnects with the same `stream_id`, it
|
||||||
|
picks up from the queue's current position; already-delivered tokens
|
||||||
|
are NOT re-sent. Resetting the accumulators on reconnect would wipe
|
||||||
|
the already-displayed content and restart the response from the first
|
||||||
|
post-reconnect token — a data-loss regression.
|
||||||
|
|
||||||
|
The "doubled response" / "stuck cursor" symptom that originally
|
||||||
|
motivated the reset is fully explained by Bug A (trailing rAF after
|
||||||
|
`done` inserting a duplicate live-turn wrapper). The Bug A fix
|
||||||
|
(_streamFinalized guard + cancelAnimationFrame in terminal handlers)
|
||||||
|
resolves both symptoms without needing a reset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_wire_sse_does_not_reset_accumulators(self):
|
||||||
|
"""Regression guard: _wireSSE must not contain a literal
|
||||||
|
accumulator-reset statement. Preserves pre-reconnect content so
|
||||||
|
the user sees the full response across a drop+reconnect."""
|
||||||
|
src = read('static/messages.js')
|
||||||
|
m = re.search(r'function _wireSSE\(source\)\{.*?\n \}', src, re.DOTALL)
|
||||||
|
assert m, "_wireSSE not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert "assistantText=''" not in fn and 'assistantText = ""' not in fn, (
|
||||||
|
"_wireSSE must NOT reset assistantText — the server does not replay "
|
||||||
|
"events on reconnect, so the reset would wipe valid pre-drop content"
|
||||||
|
)
|
||||||
|
assert "reasoningText=''" not in fn and 'reasoningText = ""' not in fn, (
|
||||||
|
"_wireSSE must NOT reset reasoningText on reconnect"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_closure_initialises_accumulators_empty(self):
|
||||||
|
"""Initial-connect safety: accumulators are initialised to empty at
|
||||||
|
the closure scope in attachLiveStream, not inside _wireSSE. That
|
||||||
|
covers the first call; reconnects must preserve whatever was
|
||||||
|
accumulated before the drop."""
|
||||||
|
src = read('static/messages.js')
|
||||||
|
m = re.search(
|
||||||
|
r'function attachLiveStream\(.*?function _closeSource',
|
||||||
|
src,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert m, "attachLiveStream prelude not found"
|
||||||
|
prelude = m.group(0)
|
||||||
|
assert "let assistantText=''" in prelude or 'let assistantText = ""' in prelude, (
|
||||||
|
"assistantText must be initialised to '' at closure scope — "
|
||||||
|
"this is the only legitimate reset; _wireSSE must not re-reset"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error_handler_guards_on_stream_finalized(self):
|
||||||
|
"""`error` must still bail out when `_streamFinalized` is true —
|
||||||
|
otherwise a trailing network 'error' event after `done` would
|
||||||
|
attempt a reconnect against a stream that already completed."""
|
||||||
|
src = read('static/messages.js')
|
||||||
|
m = re.search(r"source\.addEventListener\('error'.*?\}\);", src, re.DOTALL)
|
||||||
|
assert m, "'error' handler not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert '_streamFinalized' in fn, (
|
||||||
|
"'error' reconnect handler must bail if _streamFinalized is true"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_handle_stream_error_sets_stream_finalized(self):
|
||||||
|
"""Opus review Q1: _handleStreamError is called after the reconnect fails.
|
||||||
|
It calls renderMessages() which settles the DOM. Any pending rAF must be
|
||||||
|
cancelled before that renderMessages call — same as done/apperror/cancel."""
|
||||||
|
src = read('static/messages.js')
|
||||||
|
m = re.search(r'function _handleStreamError\(\)\{.*?\n \}', src, re.DOTALL)
|
||||||
|
assert m, "_handleStreamError not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert '_streamFinalized=true' in fn or '_streamFinalized = true' in fn, (
|
||||||
|
"_handleStreamError must set _streamFinalized=true (Opus Q1 fix)"
|
||||||
|
)
|
||||||
|
assert 'cancelAnimationFrame' in fn, (
|
||||||
|
"_handleStreamError must cancel any pending rAF before renderMessages() runs"
|
||||||
|
)
|
||||||
152
tests/test_workspace_blank_page_fix.py
Normal file
152
tests/test_workspace_blank_page_fix.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Tests for #804 — blank new-chat page loses default workspace binding
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
- syncWorkspaceDisplays() uses S._profileDefaultWorkspace as fallback when no session
|
||||||
|
- composerChip.disabled uses hasWorkspace (not hasSession) so chip is enabled on blank page
|
||||||
|
- boot.js reads default_workspace from /api/settings and sets S._profileDefaultWorkspace
|
||||||
|
- promptNewFile/promptNewFolder auto-create a session bound to default workspace
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
REPO = pathlib.Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def read(rel):
|
||||||
|
return (REPO / rel).read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncWorkspaceDisplaysFallback:
|
||||||
|
"""syncWorkspaceDisplays must show default workspace when no session."""
|
||||||
|
|
||||||
|
def test_uses_profile_default_workspace_as_fallback(self):
|
||||||
|
src = read('static/panels.js')
|
||||||
|
m = re.search(r'function syncWorkspaceDisplays\(\)\{.*?\n\}', src, re.DOTALL)
|
||||||
|
assert m, "syncWorkspaceDisplays not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert '_profileDefaultWorkspace' in fn, (
|
||||||
|
"syncWorkspaceDisplays must read S._profileDefaultWorkspace as fallback "
|
||||||
|
"when no active session is present"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_has_workspace_not_has_session_for_chip_disable(self):
|
||||||
|
src = read('static/panels.js')
|
||||||
|
m = re.search(r'function syncWorkspaceDisplays\(\)\{.*?\n\}', src, re.DOTALL)
|
||||||
|
assert m
|
||||||
|
fn = m.group(0)
|
||||||
|
# composerChip.disabled must use hasWorkspace, not hasSession
|
||||||
|
assert 'composerChip.disabled=!hasWorkspace' in fn or \
|
||||||
|
'composerChip.disabled = !hasWorkspace' in fn, (
|
||||||
|
"composerChip.disabled must use !hasWorkspace (not !hasSession) so the chip "
|
||||||
|
"is enabled on the blank new-chat page when a default workspace is configured"
|
||||||
|
)
|
||||||
|
assert 'composerChip.disabled=!hasSession' not in fn, (
|
||||||
|
"composerChip.disabled must not use !hasSession — this was the regression"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBootJsProfileDefaultWorkspace:
|
||||||
|
"""boot.js must read default_workspace from /api/settings into S._profileDefaultWorkspace."""
|
||||||
|
|
||||||
|
def test_boot_reads_default_workspace_from_settings(self):
|
||||||
|
src = read('static/boot.js')
|
||||||
|
assert '_profileDefaultWorkspace' in src, (
|
||||||
|
"boot.js must set S._profileDefaultWorkspace from the /api/settings "
|
||||||
|
"default_workspace field so it is available before any session is created"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_boot_sets_profile_default_workspace_in_settings_block(self):
|
||||||
|
"""The settings block (lines ~758-800 in boot.js) must set
|
||||||
|
S._profileDefaultWorkspace from the /api/settings response."""
|
||||||
|
src = read('static/boot.js')
|
||||||
|
# Find the settings fetch and the _profileDefaultWorkspace assignment
|
||||||
|
# and confirm both are in the same settings-read block (within ~50 lines)
|
||||||
|
ws_idx = src.find('_profileDefaultWorkspace')
|
||||||
|
settings_idx = src.find("await api('/api/settings')")
|
||||||
|
assert ws_idx != -1, "_profileDefaultWorkspace not found in boot.js"
|
||||||
|
assert settings_idx != -1, "await api('/api/settings') not found in boot.js"
|
||||||
|
# Both must be within 300 chars of each other (same block)
|
||||||
|
assert abs(ws_idx - settings_idx) < 1000, (
|
||||||
|
"S._profileDefaultWorkspace must be set in the same settings-fetch block"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromptNewFileNoSession:
|
||||||
|
"""promptNewFile/promptNewFolder must auto-create a session on blank page."""
|
||||||
|
|
||||||
|
def test_prompt_new_file_auto_creates_session(self):
|
||||||
|
src = read('static/ui.js')
|
||||||
|
m = re.search(r'async function promptNewFile\(\)\{.*?\n\}', src, re.DOTALL)
|
||||||
|
assert m, "promptNewFile not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
# Must have auto-create path (not just early return when no session)
|
||||||
|
assert '_profileDefaultWorkspace' in fn, (
|
||||||
|
"promptNewFile must read S._profileDefaultWorkspace to auto-create "
|
||||||
|
"a session when called on the blank new-chat page"
|
||||||
|
)
|
||||||
|
assert 'session/new' in fn, (
|
||||||
|
"promptNewFile must call /api/session/new to create a session "
|
||||||
|
"bound to the default workspace when S.session is null"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prompt_new_folder_auto_creates_session(self):
|
||||||
|
src = read('static/ui.js')
|
||||||
|
m = re.search(r'async function promptNewFolder\(\)\{.*?\n\}', src, re.DOTALL)
|
||||||
|
assert m, "promptNewFolder not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert '_profileDefaultWorkspace' in fn, (
|
||||||
|
"promptNewFolder must read S._profileDefaultWorkspace for auto-create path"
|
||||||
|
)
|
||||||
|
assert 'session/new' in fn, (
|
||||||
|
"promptNewFolder must call /api/session/new to create session on blank page"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prompt_new_file_still_returns_early_without_default(self):
|
||||||
|
"""If no default workspace, the function should return early (not crash)."""
|
||||||
|
src = read('static/ui.js')
|
||||||
|
m = re.search(r'async function promptNewFile\(\)\{.*?\n\}', src, re.DOTALL)
|
||||||
|
assert m
|
||||||
|
fn = m.group(0)
|
||||||
|
# Must have a guard for empty workspace
|
||||||
|
assert "if(!ws) return" in fn or "if(!ws)return" in fn, (
|
||||||
|
"promptNewFile must return early if no default workspace is configured"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkspaceSwitcherBlankPage:
|
||||||
|
"""Opus review Q6: workspace switcher dropdown must not silently fail on blank page."""
|
||||||
|
|
||||||
|
def test_switch_to_workspace_auto_creates_session(self):
|
||||||
|
src = read('static/panels.js')
|
||||||
|
m = re.search(r'async function switchToWorkspace\(.*?\n\}', src, re.DOTALL)
|
||||||
|
assert m, "switchToWorkspace not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert '_profileDefaultWorkspace' in fn or 'session/new' in fn, (
|
||||||
|
"switchToWorkspace must auto-create session on blank page (Opus Q6 fix)"
|
||||||
|
)
|
||||||
|
assert 'session/new' in fn, (
|
||||||
|
"switchToWorkspace must call /api/session/new when S.session is null"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prompt_workspace_path_auto_creates_session(self):
|
||||||
|
src = read('static/panels.js')
|
||||||
|
m = re.search(r'async function promptWorkspacePath\(\)\{.*?\n\}', src, re.DOTALL)
|
||||||
|
assert m, "promptWorkspacePath not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert 'session/new' in fn, (
|
||||||
|
"promptWorkspacePath must call /api/session/new when S.session is null"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sync_workspace_displays_dropdown_close_uses_has_workspace(self):
|
||||||
|
src = read('static/panels.js')
|
||||||
|
m = re.search(r'function syncWorkspaceDisplays\(\)\{.*?\n\}', src, re.DOTALL)
|
||||||
|
assert m, "syncWorkspaceDisplays not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
# Line 555: dropdown force-close must use hasWorkspace, not hasSession
|
||||||
|
assert '!hasWorkspace && composerDropdown' in fn or '!hasWorkspace&&composerDropdown' in fn, (
|
||||||
|
"syncWorkspaceDisplays must use !hasWorkspace (not !hasSession) to decide "
|
||||||
|
"whether to force-close the dropdown (Opus Q6 fix)"
|
||||||
|
)
|
||||||
|
assert '!hasSession && composerDropdown' not in fn, (
|
||||||
|
"Regression guard: !hasSession for dropdown close must be removed"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user