Instance version and access controls.
-
diff --git a/static/messages.js b/static/messages.js
index 8e37699..4696119 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -315,7 +315,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const d=JSON.parse(e.data);
if(d.name==='clarify') return;
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false, tid:d.tid||`live-${Date.now()}-${Math.random().toString(36).slice(2,8)}`};
- if(!Array.isArray(INFLIGHT[activeSid].toolCalls)) INFLIGHT[activeSid].toolCalls=[];
+ const inflight = INFLIGHT[activeSid] || (INFLIGHT[activeSid] = {
+ messages:[...S.messages],
+ uploaded:[],
+ toolCalls:[]
+ });
+ if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
INFLIGHT[activeSid].toolCalls.push(tc);
S.toolCalls=INFLIGHT[activeSid].toolCalls;
persistInflightState();
@@ -373,8 +378,40 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed');
});
+ source.addEventListener('title',e=>{
+ let d={};
+ try{ d=JSON.parse(e.data||'{}'); }catch(_){}
+ if((d.session_id||activeSid)!==activeSid) return;
+ const newTitle=String(d.title||'').trim();
+ if(!newTitle) return;
+ if(S.session&&S.session.session_id===activeSid){
+ S.session.title=newTitle;
+ syncTopbar();
+ }
+ if(typeof _allSessions!=='undefined'&&Array.isArray(_allSessions)){
+ const row=_allSessions.find(s=>s&&s.session_id===activeSid);
+ if(row) row.title=newTitle;
+ }
+ if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
+ else if(typeof renderSessionList==='function') renderSessionList();
+ });
+
+ source.addEventListener('title_status',e=>{
+ let d={};
+ try{ d=JSON.parse(e.data||'{}'); }catch(_){}
+ if((d.session_id||activeSid)!==activeSid) return;
+ try{
+ console.info('[title]', {
+ status:String(d.status||''),
+ reason:String(d.reason||''),
+ title:String(d.title||''),
+ raw_preview:String(d.raw_preview||''),
+ session_id:String(d.session_id||activeSid)
+ });
+ }catch(_){}
+ });
+
source.addEventListener('done',e=>{
- source.close();
const d=JSON.parse(e.data);
delete INFLIGHT[activeSid];
clearInflight();clearInflightState(activeSid);
@@ -416,6 +453,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
});
+ source.addEventListener('stream_end',e=>{
+ try{
+ const d=JSON.parse(e.data||'{}');
+ if((d.session_id||activeSid)!==activeSid) return;
+ }catch(_){}
+ source.close();
+ });
+
source.addEventListener('compressed',e=>{
// Context was auto-compressed during this turn -- show a system message
if(!S.session||S.session.session_id!==activeSid) return;
@@ -526,7 +571,36 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
}
- _wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true}));
+ (async()=>{
+ // Reattach path can carry stale stream ids after server restart; preflight
+ // status avoids opening a dead SSE URL that will 404 in the console.
+ if(reconnecting){
+ try{
+ const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
+ if(!st.active){
+ delete INFLIGHT[activeSid];
+ clearInflight();
+ clearInflightState(activeSid);
+ stopApprovalPolling();
+ stopClarifyPolling();
+ if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
+ if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true);
+ if(S.session&&S.session.session_id===activeSid){
+ S.activeStreamId=null;
+ const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
+ clearLiveToolCards();
+ removeThinking();
+ setBusy(false);
+ setComposerStatus('');
+ renderMessages();
+ renderSessionList();
+ }
+ return;
+ }
+ }catch(_){}
+ }
+ _wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true}));
+ })();
}
diff --git a/tests/test_issue487b.py b/tests/test_issue487b.py
index 097f043..18e4374 100644
--- a/tests/test_issue487b.py
+++ b/tests/test_issue487b.py
@@ -1,4 +1,4 @@
-"""
+r"""
Regression test for image src URL corruption by the autolink pass.
Bug: the _al_stash before the autolink pass only stashed
tags.
diff --git a/tests/test_sprint41.py b/tests/test_sprint41.py
index 5e0e5f5..19e112e 100644
--- a/tests/test_sprint41.py
+++ b/tests/test_sprint41.py
@@ -18,6 +18,7 @@ import unittest
REPO_ROOT = pathlib.Path(__file__).parent.parent
CSS = (REPO_ROOT / "static" / "style.css").read_text()
HTML = (REPO_ROOT / "static" / "index.html").read_text()
+MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text()
STREAMING_PY = (REPO_ROOT / "api" / "streaming.py").read_text()
@@ -125,5 +126,93 @@ class TestWorkspacePanelButtons(unittest.TestCase):
"mobile-close-btn must have aria-label for accessibility")
+class TestIssue495TitleStreaming(unittest.TestCase):
+ """Regression checks for issue #495 title SSE behavior."""
+
+ def test_streaming_has_llm_title_helper(self):
+ self.assertIn(
+ "def _generate_llm_session_title_for_agent(",
+ STREAMING_PY,
+ "streaming.py should define an agent-backed LLM title helper for session titles",
+ )
+
+ def test_streaming_rejects_generic_completion_titles(self):
+ self.assertIn(
+ "测试完成",
+ STREAMING_PY,
+ "streaming.py should reject generic completion phrases as session titles",
+ )
+ self.assertIn(
+ "all set",
+ STREAMING_PY,
+ "streaming.py should reject generic English completion phrases as session titles",
+ )
+
+ def test_streaming_uses_reasoning_split_for_minimax_titles(self):
+ self.assertIn(
+ "reasoning_split",
+ STREAMING_PY,
+ "streaming.py should request MiniMax title calls with reasoning_split so final text is separated from thinking",
+ )
+
+ def test_streaming_emits_title_sse_event(self):
+ self.assertIn(
+ "put_event('title', {'session_id': s.session_id, 'title': s.title})",
+ STREAMING_PY,
+ "streaming.py should emit a title SSE event when title is updated",
+ )
+
+ def test_streaming_emits_title_status_sse_event(self):
+ self.assertIn(
+ "put_event('title_status', payload)",
+ STREAMING_PY,
+ "streaming.py should emit a title_status SSE event for title generation diagnostics",
+ )
+
+ def test_streaming_emits_stream_end_event(self):
+ self.assertIn(
+ "put_event('stream_end', {'session_id': session_id})",
+ STREAMING_PY,
+ "background title path should end the SSE stream with stream_end",
+ )
+
+ def test_frontend_listens_for_title_event(self):
+ self.assertIn(
+ "addEventListener('title'",
+ MESSAGES_JS,
+ "messages.js should listen for title SSE events",
+ )
+
+ def test_frontend_listens_for_title_status_event(self):
+ self.assertIn(
+ "addEventListener('title_status'",
+ MESSAGES_JS,
+ "messages.js should listen for title_status SSE events",
+ )
+ self.assertIn(
+ "console.info('[title]'",
+ MESSAGES_JS,
+ "messages.js should log title generation diagnostics to the browser console",
+ )
+
+ def test_frontend_refreshes_title_ui_after_title_event(self):
+ self.assertIn(
+ "syncTopbar()",
+ MESSAGES_JS,
+ "messages.js title listener should sync top bar title",
+ )
+ self.assertTrue(
+ ("renderSessionListFromCache()" in MESSAGES_JS) or ("renderSessionList()" in MESSAGES_JS),
+ "messages.js title listener should refresh session list UI",
+ )
+
+ def test_frontend_waits_for_stream_end_before_closing(self):
+ self.assertIn(
+ "addEventListener('stream_end'",
+ MESSAGES_JS,
+ "messages.js should close SSE connection on stream_end (not immediately on done)",
+ )
+
+
if __name__ == "__main__":
unittest.main()