fix: live reasoning, tool progress, in-flight session recovery (#367)
* fix: preserve live session output across chat switches (cherry picked from commit 401e3b643d25e8dad8c06883b478b3c3073f07a5) * fix: preserve todo state after session reload (cherry picked from commit 7ee093ba19978af23b79148df2f2347e2f1e5bde) * fix: preserve live assistant anchor across rerenders * fix: stream live reasoning and tool progress * fix: recover inflight session state after reload * fix: add loadInflightState stub + CHANGELOG v0.50.21 - static/ui.js: add loadInflightState() function (currently returns null — the typeof guard in sessions.js means reload recovery works via the else-path attachLiveStream call; this stub satisfies the guard cleanly and documents the extension point for future localStorage-backed state) - CHANGELOG.md: v0.50.21 entry; 960 tests (up from 949) --------- Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -10,6 +10,17 @@
|
|||||||
- **Workspace file downloads no longer crash for Unicode filenames** (`api/routes.py`): Clicking a PDF or other file with Chinese, Japanese, Arabic, or other non-ASCII characters in its name caused a `UnicodeEncodeError` because Python's HTTP server requires header values to be latin-1 encodable. A new `_content_disposition_value(disposition, filename)` helper centralises `Content-Disposition` generation: it strips CR/LF (injection guard), builds an ASCII fallback for the legacy `filename=` parameter (non-ASCII chars replaced with `_`), and preserves the full UTF-8 name in `filename*=UTF-8''...` per RFC 5987. Both `attachment` and `inline` responses use it.
|
- **Workspace file downloads no longer crash for Unicode filenames** (`api/routes.py`): Clicking a PDF or other file with Chinese, Japanese, Arabic, or other non-ASCII characters in its name caused a `UnicodeEncodeError` because Python's HTTP server requires header values to be latin-1 encodable. A new `_content_disposition_value(disposition, filename)` helper centralises `Content-Disposition` generation: it strips CR/LF (injection guard), builds an ASCII fallback for the legacy `filename=` parameter (non-ASCII chars replaced with `_`), and preserves the full UTF-8 name in `filename*=UTF-8''...` per RFC 5987. Both `attachment` and `inline` responses use it.
|
||||||
- 2 new integration tests in `tests/test_sprint29.py` covering Chinese filenames for both download and inline responses, verifying the header is latin-1 encodable and `filename*=UTF-8''` is present; 924 tests total (up from 922)
|
- 2 new integration tests in `tests/test_sprint29.py` covering Chinese filenames for both download and inline responses, verifying the header is latin-1 encodable and `filename*=UTF-8''` is present; 924 tests total (up from 922)
|
||||||
|
|
||||||
|
## [v0.50.21] Live reasoning, tool progress, and in-flight session recovery (PR #367)
|
||||||
|
|
||||||
|
- **Live reasoning cards during streaming** (`static/ui.js`, `static/messages.js`): The generic thinking spinner now upgrades to a live reasoning card when the backend streams reasoning text. `_thinkingMarkup(text)` and `updateThinking(text)` centralize the markup so the spinner and card share the same DOM slot. Works with models that emit reasoning via the agent's `reasoning_callback` or `tool_progress_callback`.
|
||||||
|
- **`tool_complete` SSE events** (`api/streaming.py`, `static/messages.js`): Tool progress callback now accepts the current agent signature `on_tool(*cb_args, **cb_kwargs)` — handles both the old 3-arg `(name, preview, args)` form and the new 4-arg `(event_type, name, preview, args)` form. `tool.completed` events transition live tool cards from running to done cleanly.
|
||||||
|
- **In-flight session state stable across switches** (`static/messages.js`, `static/sessions.js`): `attachLiveStream` refactored out of `send()` into a standalone function; partial assistant text mirrored into `INFLIGHT` state on every token; `data-live-assistant` DOM anchor preserved across `renderMessages()` calls so switching away and back doesn't lose or duplicate live output.
|
||||||
|
- **Reload recovery** (`api/models.py`, `api/routes.py`, `api/streaming.py`, `static/sessions.js`): `active_stream_id`, `pending_user_message`, `pending_attachments`, and `pending_started_at` now persisted on the session object before streaming starts and cleared on completion (or exception). `/api/session` returns these fields. After a page reload or session switch, `loadSession()` detects `active_stream_id` and calls `attachLiveStream(..., {reconnecting:true})` to reattach to the live SSE stream.
|
||||||
|
- **Session-scoped message queue** (`static/ui.js`, `static/messages.js`): Global `MSG_QUEUE` replaced with `SESSION_QUEUES` keyed by session ID. Queued follow-up messages are associated with the session they were typed in and only drained when that session becomes idle — no cross-session bleed.
|
||||||
|
- **`newSession()` idle reset** (`static/sessions.js`): Sets `S.busy=false`, `S.activeStreamId=null`, clears the cancel button, resets composer status — ensures a fresh chat is immediately usable even if another session's stream is still running.
|
||||||
|
- **Todos survive session reload** (`static/panels.js`): `loadTodos()` now reads from `S.session.messages` (raw, includes tool-role messages) rather than `S.messages` (filtered display), so todo state reconstructed from tool outputs survives reloads.
|
||||||
|
- 12 new regression tests in `tests/test_regressions.py`; 960 tests total (up from 949)
|
||||||
|
|
||||||
## [v0.50.20] Silent error fix, stale model cleanup, live model fetching (fixes #373, #374, #375)
|
## [v0.50.20] Silent error fix, stale model cleanup, live model fetching (fixes #373, #374, #375)
|
||||||
|
|
||||||
### Fix: Chat no longer silently swallows agent failures (fixes #373)
|
### Fix: Chat no longer silently swallows agent failures (fixes #373)
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ class Session:
|
|||||||
project_id: str=None, profile=None,
|
project_id: str=None, profile=None,
|
||||||
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
|
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
|
||||||
personality=None,
|
personality=None,
|
||||||
|
active_stream_id: str=None,
|
||||||
|
pending_user_message: str=None,
|
||||||
|
pending_attachments=None,
|
||||||
|
pending_started_at=None,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -61,6 +65,10 @@ class Session:
|
|||||||
self.output_tokens = output_tokens or 0
|
self.output_tokens = output_tokens or 0
|
||||||
self.estimated_cost = estimated_cost
|
self.estimated_cost = estimated_cost
|
||||||
self.personality = personality
|
self.personality = personality
|
||||||
|
self.active_stream_id = active_stream_id
|
||||||
|
self.pending_user_message = pending_user_message
|
||||||
|
self.pending_attachments = pending_attachments or []
|
||||||
|
self.pending_started_at = pending_started_at
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path(self):
|
def path(self):
|
||||||
|
|||||||
@@ -365,6 +365,10 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
raw = s.compact() | {
|
raw = s.compact() | {
|
||||||
"messages": s.messages,
|
"messages": s.messages,
|
||||||
"tool_calls": getattr(s, "tool_calls", []),
|
"tool_calls": getattr(s, "tool_calls", []),
|
||||||
|
"active_stream_id": getattr(s, "active_stream_id", None),
|
||||||
|
"pending_user_message": getattr(s, "pending_user_message", None),
|
||||||
|
"pending_attachments": getattr(s, "pending_attachments", []),
|
||||||
|
"pending_started_at": getattr(s, "pending_started_at", None),
|
||||||
}
|
}
|
||||||
return j(handler, {"session": redact_session_data(raw)})
|
return j(handler, {"session": redact_session_data(raw)})
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -1683,11 +1687,15 @@ def _handle_chat_start(handler, body):
|
|||||||
attachments = [str(a) for a in (body.get("attachments") or [])][:20]
|
attachments = [str(a) for a in (body.get("attachments") or [])][:20]
|
||||||
workspace = str(Path(body.get("workspace") or s.workspace).expanduser().resolve())
|
workspace = str(Path(body.get("workspace") or s.workspace).expanduser().resolve())
|
||||||
model = body.get("model") or s.model
|
model = body.get("model") or s.model
|
||||||
|
stream_id = uuid.uuid4().hex
|
||||||
s.workspace = workspace
|
s.workspace = workspace
|
||||||
s.model = model
|
s.model = model
|
||||||
|
s.active_stream_id = stream_id
|
||||||
|
s.pending_user_message = msg
|
||||||
|
s.pending_attachments = attachments
|
||||||
|
s.pending_started_at = time.time()
|
||||||
s.save()
|
s.save()
|
||||||
set_last_workspace(workspace)
|
set_last_workspace(workspace)
|
||||||
stream_id = uuid.uuid4().hex
|
|
||||||
q = queue.Queue()
|
q = queue.Queue()
|
||||||
with STREAMS_LOCK:
|
with STREAMS_LOCK:
|
||||||
STREAMS[stream_id] = q
|
STREAMS[stream_id] = q
|
||||||
|
|||||||
@@ -172,12 +172,47 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
|||||||
_token_sent = True
|
_token_sent = True
|
||||||
put('token', {'text': text})
|
put('token', {'text': text})
|
||||||
|
|
||||||
def on_tool(name, preview, args):
|
def on_reasoning(text):
|
||||||
|
if text is None:
|
||||||
|
return
|
||||||
|
put('reasoning', {'text': str(text)})
|
||||||
|
|
||||||
|
def on_tool(*cb_args, **cb_kwargs):
|
||||||
|
event_type = None
|
||||||
|
name = None
|
||||||
|
preview = None
|
||||||
|
args = None
|
||||||
|
|
||||||
|
if len(cb_args) >= 4:
|
||||||
|
event_type, name, preview, args = cb_args[:4]
|
||||||
|
elif len(cb_args) == 3:
|
||||||
|
name, preview, args = cb_args
|
||||||
|
event_type = 'tool.started'
|
||||||
|
elif len(cb_args) == 2:
|
||||||
|
event_type, name = cb_args
|
||||||
|
elif len(cb_args) == 1:
|
||||||
|
name = cb_args[0]
|
||||||
|
event_type = 'tool.started'
|
||||||
|
|
||||||
|
if event_type in ('reasoning.available', '_thinking'):
|
||||||
|
reason_text = preview if event_type == 'reasoning.available' else name
|
||||||
|
if reason_text:
|
||||||
|
put('reasoning', {'text': str(reason_text)})
|
||||||
|
return
|
||||||
|
|
||||||
args_snap = {}
|
args_snap = {}
|
||||||
if isinstance(args, dict):
|
if isinstance(args, dict):
|
||||||
for k, v in list(args.items())[:4]:
|
for k, v in list(args.items())[:4]:
|
||||||
s2 = str(v); args_snap[k] = s2[:120]+('...' if len(s2)>120 else '')
|
s2 = str(v)
|
||||||
put('tool', {'name': name, 'preview': preview, 'args': args_snap})
|
args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '')
|
||||||
|
|
||||||
|
if event_type in (None, 'tool.started'):
|
||||||
|
put('tool', {
|
||||||
|
'event_type': event_type or 'tool.started',
|
||||||
|
'name': name,
|
||||||
|
'preview': preview,
|
||||||
|
'args': args_snap,
|
||||||
|
})
|
||||||
# Fallback: poll for pending approval in case notify_cb wasn't
|
# Fallback: poll for pending approval in case notify_cb wasn't
|
||||||
# registered (e.g. older approval module without gateway support).
|
# registered (e.g. older approval module without gateway support).
|
||||||
try:
|
try:
|
||||||
@@ -189,6 +224,18 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
|||||||
put('approval', p)
|
put('approval', p)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == 'tool.completed':
|
||||||
|
put('tool_complete', {
|
||||||
|
'event_type': event_type,
|
||||||
|
'name': name,
|
||||||
|
'preview': preview,
|
||||||
|
'args': args_snap,
|
||||||
|
'duration': cb_kwargs.get('duration'),
|
||||||
|
'is_error': bool(cb_kwargs.get('is_error', False)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
_AIAgent = _get_ai_agent()
|
_AIAgent = _get_ai_agent()
|
||||||
if _AIAgent is None:
|
if _AIAgent is None:
|
||||||
@@ -252,6 +299,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
session_db=_session_db,
|
session_db=_session_db,
|
||||||
stream_delta_callback=on_token,
|
stream_delta_callback=on_token,
|
||||||
|
reasoning_callback=on_reasoning,
|
||||||
tool_progress_callback=on_tool,
|
tool_progress_callback=on_tool,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -458,6 +506,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
|||||||
'assistant_msg_idx': asst_idx, 'args': args_snap,
|
'assistant_msg_idx': asst_idx, 'args': args_snap,
|
||||||
})
|
})
|
||||||
s.tool_calls = tool_calls
|
s.tool_calls = tool_calls
|
||||||
|
s.active_stream_id = None
|
||||||
|
s.pending_user_message = None
|
||||||
|
s.pending_attachments = []
|
||||||
|
s.pending_started_at = None
|
||||||
# Tag the matching user message with attachment filenames for display on reload
|
# Tag the matching user message with attachment filenames for display on reload
|
||||||
# Only tag a user message whose content relates to this turn's text
|
# Only tag a user message whose content relates to this turn's text
|
||||||
# (msg_text is the full message including the [Attached files: ...] suffix)
|
# (msg_text is the full message including the [Attached files: ...] suffix)
|
||||||
@@ -516,6 +568,15 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)
|
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)
|
||||||
|
if s is not None:
|
||||||
|
s.active_stream_id = None
|
||||||
|
s.pending_user_message = None
|
||||||
|
s.pending_attachments = []
|
||||||
|
s.pending_started_at = None
|
||||||
|
try:
|
||||||
|
s.save()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
err_str = str(e)
|
err_str = str(e)
|
||||||
# Detect rate limit errors specifically so the client can show a helpful card
|
# Detect rate limit errors specifically so the client can show a helpful card
|
||||||
# rather than the generic "Connection lost" message
|
# rather than the generic "Connection lost" message
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ async function send(){
|
|||||||
// If busy, queue the message instead of dropping it
|
// If busy, queue the message instead of dropping it
|
||||||
if(S.busy){
|
if(S.busy){
|
||||||
if(text){
|
if(text){
|
||||||
MSG_QUEUE.push(text);
|
if(!S.session){await newSession();await renderSessionList();}
|
||||||
|
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles]});
|
||||||
$('msg').value='';autoResize();
|
$('msg').value='';autoResize();
|
||||||
updateQueueBadge();
|
S.pendingFiles=[];renderTray();
|
||||||
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'\u2026':''}"`,2000);
|
updateQueueBadge(S.session.session_id);
|
||||||
|
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'…':''}"`,2000);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,7 @@ async function send(){
|
|||||||
S.toolCalls=[]; // clear tool calls from previous turn
|
S.toolCalls=[]; // clear tool calls from previous turn
|
||||||
clearLiveToolCards(); // clear any leftover live cards from last turn
|
clearLiveToolCards(); // clear any leftover live cards from last turn
|
||||||
S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true);
|
S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true);
|
||||||
INFLIGHT[activeSid]={messages:[...S.messages],uploaded};
|
INFLIGHT[activeSid]={messages:[...S.messages],uploaded,toolCalls:[]};
|
||||||
startApprovalPolling(activeSid);
|
startApprovalPolling(activeSid);
|
||||||
S.activeStreamId = null; // will be set after stream starts
|
S.activeStreamId = null; // will be set after stream starts
|
||||||
|
|
||||||
@@ -81,7 +83,32 @@ async function send(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open SSE stream and render tokens live
|
// Open SSE stream and render tokens live
|
||||||
|
attachLiveStream(activeSid, streamId, uploaded);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIVE_STREAMS={};
|
||||||
|
|
||||||
|
function closeLiveStream(sessionId, streamId){
|
||||||
|
const live=LIVE_STREAMS[sessionId];
|
||||||
|
if(!live) return;
|
||||||
|
if(streamId&&live.streamId!==streamId) return;
|
||||||
|
try{live.source.close();}catch(_){ }
|
||||||
|
delete LIVE_STREAMS[sessionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||||
|
if(!activeSid||!streamId) return;
|
||||||
|
const reconnecting=!!options.reconnecting;
|
||||||
|
closeLiveStream(activeSid);
|
||||||
|
if(!INFLIGHT[activeSid]) INFLIGHT[activeSid]={messages:[...S.messages],uploaded:[...uploaded],toolCalls:[]};
|
||||||
|
else {
|
||||||
|
if(uploaded.length) INFLIGHT[activeSid].uploaded=[...uploaded];
|
||||||
|
if(!Array.isArray(INFLIGHT[activeSid].toolCalls)) INFLIGHT[activeSid].toolCalls=[];
|
||||||
|
}
|
||||||
|
|
||||||
let assistantText='';
|
let assistantText='';
|
||||||
|
let reasoningText='';
|
||||||
let assistantRow=null;
|
let assistantRow=null;
|
||||||
let assistantBody=null;
|
let assistantBody=null;
|
||||||
// Thinking tag patterns for streaming display
|
// Thinking tag patterns for streaming display
|
||||||
@@ -90,8 +117,45 @@ async function send(){
|
|||||||
{open:'<|channel>thought\n',close:'<channel|>'}
|
{open:'<|channel>thought\n',close:'<channel|>'}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function _isActiveSession(){
|
||||||
|
return !!(S.session&&S.session.session_id===activeSid);
|
||||||
|
}
|
||||||
|
function _closeSource(){
|
||||||
|
closeLiveStream(activeSid, streamId);
|
||||||
|
}
|
||||||
|
function syncInflightAssistantMessage(){
|
||||||
|
const inflight=INFLIGHT[activeSid];
|
||||||
|
if(!inflight) return;
|
||||||
|
if(!Array.isArray(inflight.messages)) inflight.messages=[];
|
||||||
|
let assistantIdx=-1;
|
||||||
|
for(let i=inflight.messages.length-1;i>=0;i--){
|
||||||
|
const msg=inflight.messages[i];
|
||||||
|
if(msg&&msg.role==='assistant'&&msg._live){assistantIdx=i;break;}
|
||||||
|
}
|
||||||
|
const ts=Date.now()/1000;
|
||||||
|
if(assistantIdx>=0){
|
||||||
|
inflight.messages[assistantIdx].content=assistantText;
|
||||||
|
inflight.messages[assistantIdx].reasoning=reasoningText||undefined;
|
||||||
|
inflight.messages[assistantIdx]._ts=inflight.messages[assistantIdx]._ts||ts;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inflight.messages.push({role:'assistant',content:assistantText,reasoning:reasoningText||undefined,_live:true,_ts:ts});
|
||||||
|
}
|
||||||
function ensureAssistantRow(){
|
function ensureAssistantRow(){
|
||||||
if(assistantRow)return;
|
if(!_isActiveSession()) return;
|
||||||
|
if(assistantRow&&!assistantRow.isConnected){assistantRow=null;assistantBody=null;}
|
||||||
|
if(!assistantRow){
|
||||||
|
const existing=$('msgInner').querySelector('.msg-row[data-live-assistant="1"]');
|
||||||
|
if(existing){
|
||||||
|
assistantRow=existing;
|
||||||
|
assistantBody=existing.querySelector('.msg-body');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(assistantRow){
|
||||||
|
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
removeThinking();
|
removeThinking();
|
||||||
const tr=$('toolRunningRow');if(tr)tr.remove();
|
const tr=$('toolRunningRow');if(tr)tr.remove();
|
||||||
$('emptyState').style.display='none';
|
$('emptyState').style.display='none';
|
||||||
@@ -115,6 +179,7 @@ async function send(){
|
|||||||
// and hiding content still inside an open thinking block.
|
// and hiding content still inside an open thinking block.
|
||||||
function _streamDisplay(){
|
function _streamDisplay(){
|
||||||
const raw=assistantText;
|
const raw=assistantText;
|
||||||
|
if(reasoningText) return raw;
|
||||||
for(const {open,close} of _thinkPairs){
|
for(const {open,close} of _thinkPairs){
|
||||||
// Trim leading whitespace before checking for the open tag — some models
|
// Trim leading whitespace before checking for the open tag — some models
|
||||||
// (e.g. MiniMax) emit newlines before <think>.
|
// (e.g. MiniMax) emit newlines before <think>.
|
||||||
@@ -134,15 +199,52 @@ async function send(){
|
|||||||
}
|
}
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
function _parseStreamState(){
|
||||||
|
const raw=assistantText;
|
||||||
|
if(reasoningText){
|
||||||
|
return {thinkingText:reasoningText, displayText:_streamDisplay(), inThinking:false};
|
||||||
|
}
|
||||||
|
for(const {open,close} of _thinkPairs){
|
||||||
|
const trimmed=raw.trimStart();
|
||||||
|
if(trimmed.startsWith(open)){
|
||||||
|
const ci=trimmed.indexOf(close,open.length);
|
||||||
|
if(ci!==-1){
|
||||||
|
return {
|
||||||
|
thinkingText: trimmed.slice(open.length, ci).trim(),
|
||||||
|
displayText: trimmed.slice(ci+close.length).replace(/^\s+/,''),
|
||||||
|
inThinking:false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
thinkingText: trimmed.slice(open.length).trim(),
|
||||||
|
displayText:'',
|
||||||
|
inThinking:true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if(open.startsWith(trimmed)){
|
||||||
|
return {thinkingText:'', displayText:'', inThinking:true};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {thinkingText:'', displayText:raw, inThinking:false};
|
||||||
|
}
|
||||||
|
function _renderLiveThinking(parsed){
|
||||||
|
const text=(parsed&&parsed.thinkingText)||'';
|
||||||
|
if(text||(parsed&&parsed.inThinking)){
|
||||||
|
if(typeof updateThinking==='function') updateThinking(text||'Thinking…');
|
||||||
|
else appendThinking();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeThinking();
|
||||||
|
}
|
||||||
function _scheduleRender(){
|
function _scheduleRender(){
|
||||||
if(_renderPending) return;
|
if(_renderPending) return;
|
||||||
_renderPending=true;
|
_renderPending=true;
|
||||||
requestAnimationFrame(()=>{
|
requestAnimationFrame(()=>{
|
||||||
_renderPending=false;
|
_renderPending=false;
|
||||||
|
const parsed=_parseStreamState();
|
||||||
|
_renderLiveThinking(parsed);
|
||||||
if(assistantBody){
|
if(assistantBody){
|
||||||
const txt=_streamDisplay();
|
assistantBody.innerHTML=parsed.displayText?renderMd(parsed.displayText):'';
|
||||||
const isThinking=!txt&&assistantText.length>0;
|
|
||||||
assistantBody.innerHTML=txt?renderMd(txt):(isThinking?'<span style="color:var(--muted);font-size:13px">Thinking\u2026</span>':'');
|
|
||||||
}
|
}
|
||||||
scrollIfPinned();
|
scrollIfPinned();
|
||||||
});
|
});
|
||||||
@@ -153,17 +255,59 @@ async function send(){
|
|||||||
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);
|
||||||
assistantText+=d.text;
|
assistantText+=d.text;
|
||||||
|
syncInflightAssistantMessage();
|
||||||
|
if(!S.session||S.session.session_id!==activeSid) return;
|
||||||
|
|
||||||
ensureAssistantRow();
|
ensureAssistantRow();
|
||||||
_scheduleRender();
|
_scheduleRender();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
source.addEventListener('reasoning',e=>{
|
||||||
|
const d=JSON.parse(e.data);
|
||||||
|
reasoningText += d.text || '';
|
||||||
|
syncInflightAssistantMessage();
|
||||||
|
if(!S.session||S.session.session_id!==activeSid) return;
|
||||||
|
_scheduleRender();
|
||||||
|
});
|
||||||
|
|
||||||
source.addEventListener('tool',e=>{
|
source.addEventListener('tool',e=>{
|
||||||
const d=JSON.parse(e.data);
|
const d=JSON.parse(e.data);
|
||||||
|
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=[];
|
||||||
|
INFLIGHT[activeSid].toolCalls.push(tc);
|
||||||
|
S.toolCalls=INFLIGHT[activeSid].toolCalls;
|
||||||
|
|
||||||
if(!S.session||S.session.session_id!==activeSid) return;
|
if(!S.session||S.session.session_id!==activeSid) return;
|
||||||
removeThinking();
|
removeThinking();
|
||||||
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
|
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
|
||||||
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false};
|
appendLiveToolCard(tc);
|
||||||
S.toolCalls.push(tc);
|
scrollIfPinned();
|
||||||
|
});
|
||||||
|
|
||||||
|
source.addEventListener('tool_complete',e=>{
|
||||||
|
const d=JSON.parse(e.data);
|
||||||
|
const inflight=INFLIGHT[activeSid];
|
||||||
|
if(!inflight) return;
|
||||||
|
if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
|
||||||
|
let tc=null;
|
||||||
|
for(let i=inflight.toolCalls.length-1;i>=0;i--){
|
||||||
|
const cur=inflight.toolCalls[i];
|
||||||
|
if(cur&&cur.done===false&&(!d.name||cur.name===d.name)){
|
||||||
|
tc=cur;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!tc){
|
||||||
|
tc={name:d.name||'tool', preview:d.preview||'', args:d.args||{}, snippet:'', done:true};
|
||||||
|
inflight.toolCalls.push(tc);
|
||||||
|
}
|
||||||
|
tc.preview=d.preview||tc.preview||'';
|
||||||
|
tc.args=d.args||tc.args||{};
|
||||||
|
tc.done=true;
|
||||||
|
tc.is_error=!!d.is_error;
|
||||||
|
if(d.duration!==undefined) tc.duration=d.duration;
|
||||||
|
S.toolCalls=inflight.toolCalls;
|
||||||
|
if(!S.session||S.session.session_id!==activeSid) return;
|
||||||
appendLiveToolCard(tc);
|
appendLiveToolCard(tc);
|
||||||
scrollIfPinned();
|
scrollIfPinned();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -308,10 +308,11 @@ async function cronDelete(id) {
|
|||||||
function loadTodos() {
|
function loadTodos() {
|
||||||
const panel = $('todoPanel');
|
const panel = $('todoPanel');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;
|
||||||
// Parse the most recent todo state from message history
|
// Parse the most recent todo state from message history
|
||||||
let todos = [];
|
let todos = [];
|
||||||
for (let i = S.messages.length - 1; i >= 0; i--) {
|
for (let i = sourceMessages.length - 1; i >= 0; i--) {
|
||||||
const m = S.messages[i];
|
const m = sourceMessages[i];
|
||||||
if (m && m.role === 'tool') {
|
if (m && m.role === 'tool') {
|
||||||
try {
|
try {
|
||||||
const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
|
const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const ICONS={
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function newSession(flash){
|
async function newSession(flash){
|
||||||
MSG_QUEUE.length=0;updateQueueBadge();
|
updateQueueBadge();
|
||||||
S.toolCalls=[];
|
S.toolCalls=[];
|
||||||
clearLiveToolCards();
|
clearLiveToolCards();
|
||||||
// Use profile default workspace for new sessions after a profile switch (one-shot),
|
// Use profile default workspace for new sessions after a profile switch (one-shot),
|
||||||
@@ -20,9 +20,19 @@ async function newSession(flash){
|
|||||||
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
|
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
|
||||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
|
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
|
||||||
S.session=data.session;S.messages=data.session.messages||[];
|
S.session=data.session;S.messages=data.session.messages||[];
|
||||||
|
S.lastUsage={...(data.session.last_usage||{})};
|
||||||
if(flash)S.session._flash=true;
|
if(flash)S.session._flash=true;
|
||||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||||
syncTopbar();await loadDir('.');renderMessages();
|
// Reset per-session visual state: a fresh chat is idle even if another
|
||||||
|
// conversation is still streaming in the background.
|
||||||
|
S.busy=false;
|
||||||
|
S.activeStreamId=null;
|
||||||
|
updateSendBtn();
|
||||||
|
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||||
|
setStatus('');
|
||||||
|
setComposerStatus('');
|
||||||
|
updateQueueBadge(S.session.session_id);
|
||||||
|
syncTopbar();renderMessages();loadDir('.');
|
||||||
// don't call renderSessionList here - callers do it when needed
|
// don't call renderSessionList here - callers do it when needed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,40 +40,74 @@ async function loadSession(sid){
|
|||||||
stopApprovalPolling();hideApprovalCard();
|
stopApprovalPolling();hideApprovalCard();
|
||||||
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
|
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
|
||||||
S.session=data.session;
|
S.session=data.session;
|
||||||
|
S.lastUsage={...(data.session.last_usage||{})};
|
||||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||||
// B9: sanitize empty assistant messages that can appear when agent only ran tool calls
|
const activeStreamId=data.session.active_stream_id||null;
|
||||||
data.session.messages=(data.session.messages||[]).filter(m=>{
|
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
|
||||||
if(!m||!m.role)return false;
|
const stored=loadInflightState(sid, activeStreamId);
|
||||||
if(m.role==='tool')return false;
|
if(stored){
|
||||||
if(m.role==='assistant'){let c=m.content||'';if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('');return String(c).trim().length>0;}
|
INFLIGHT[sid]={
|
||||||
return true;
|
messages:Array.isArray(stored.messages)&&stored.messages.length?stored.messages:[...(data.session.messages||[])],
|
||||||
});
|
uploaded:Array.isArray(stored.uploaded)?stored.uploaded:[...(data.session.pending_attachments||[])],
|
||||||
|
toolCalls:Array.isArray(stored.toolCalls)?stored.toolCalls:[],
|
||||||
|
reattach:true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep raw session.messages intact so side panels (e.g. Todos) can still
|
||||||
|
// reconstruct state from tool outputs after reload. Visible transcript rows
|
||||||
|
// are filtered later by renderMessages().
|
||||||
if(INFLIGHT[sid]){
|
if(INFLIGHT[sid]){
|
||||||
S.messages=INFLIGHT[sid].messages;
|
S.messages=INFLIGHT[sid].messages;
|
||||||
// Restore live tool cards for this in-flight session
|
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
|
||||||
|
S.busy=true;
|
||||||
|
syncTopbar();renderMessages();appendThinking();loadDir('.');
|
||||||
clearLiveToolCards();
|
clearLiveToolCards();
|
||||||
|
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||||||
for(const tc of (S.toolCalls||[])){
|
for(const tc of (S.toolCalls||[])){
|
||||||
if(tc&&tc.name) appendLiveToolCard(tc);
|
if(tc&&tc.name) appendLiveToolCard(tc);
|
||||||
}
|
}
|
||||||
syncTopbar();await loadDir('.');renderMessages();appendThinking();
|
|
||||||
setBusy(true);setComposerStatus('');
|
setBusy(true);setComposerStatus('');
|
||||||
startApprovalPolling(sid);
|
startApprovalPolling(sid);
|
||||||
|
S.activeStreamId=activeStreamId;
|
||||||
|
const _cb=$('btnCancel');if(_cb&&activeStreamId)_cb.style.display='inline-flex';
|
||||||
|
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
||||||
|
INFLIGHT[sid].reattach=false;
|
||||||
|
attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
|
||||||
|
}
|
||||||
}else{
|
}else{
|
||||||
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
|
updateQueueBadge(sid);
|
||||||
S.messages=data.session.messages||[];
|
S.messages=data.session.messages||[];
|
||||||
|
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null;
|
||||||
|
if(pendingMsg) S.messages.push(pendingMsg);
|
||||||
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
|
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
|
||||||
|
clearLiveToolCards();
|
||||||
|
if(activeStreamId){
|
||||||
|
S.busy=true;
|
||||||
|
S.activeStreamId=activeStreamId;
|
||||||
|
updateSendBtn();
|
||||||
|
const _cb=$('btnCancel');if(_cb)_cb.style.display='inline-flex';
|
||||||
|
setStatus('');
|
||||||
|
setComposerStatus('');
|
||||||
|
syncTopbar();renderMessages();appendThinking();loadDir('.');
|
||||||
|
updateQueueBadge(sid);
|
||||||
|
startApprovalPolling(sid);
|
||||||
|
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
|
||||||
|
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
|
||||||
|
}else{
|
||||||
// Reset per-session visual state: the viewed session is idle even if another
|
// Reset per-session visual state: the viewed session is idle even if another
|
||||||
// session's stream is still running in the background.
|
// session's stream is still running in the background.
|
||||||
// We directly update the DOM instead of calling setBusy(false), because
|
// We directly update the DOM instead of calling setBusy(false), because
|
||||||
// setBusy(false) drains MSG_QUEUE which we don't want here.
|
// setBusy(false) drains the viewed session's queued follow-up turns.
|
||||||
S.busy=false;
|
S.busy=false;
|
||||||
S.activeStreamId=null;
|
S.activeStreamId=null;
|
||||||
updateSendBtn();
|
updateSendBtn();
|
||||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||||
setStatus('');
|
setStatus('');
|
||||||
setComposerStatus('');
|
setComposerStatus('');
|
||||||
clearLiveToolCards();
|
updateQueueBadge(sid);
|
||||||
syncTopbar();await loadDir('.');renderMessages();highlightCode();
|
syncTopbar();renderMessages();highlightCode();loadDir('.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Sync context usage indicator from session data
|
// Sync context usage indicator from session data
|
||||||
const _s=S.session;
|
const _s=S.session;
|
||||||
|
|||||||
126
static/ui.js
126
static/ui.js
@@ -1,7 +1,28 @@
|
|||||||
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default'};
|
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default'};
|
||||||
const INFLIGHT={}; // keyed by session_id while request in-flight
|
const INFLIGHT={}; // keyed by session_id while request in-flight
|
||||||
const MSG_QUEUE=[]; // messages queued while a request is in-flight
|
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
|
||||||
const $=id=>document.getElementById(id);
|
const $=id=>document.getElementById(id);
|
||||||
|
function _getSessionQueue(sid, create=false){
|
||||||
|
if(!sid) return [];
|
||||||
|
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
|
||||||
|
return SESSION_QUEUES[sid]||[];
|
||||||
|
}
|
||||||
|
function queueSessionMessage(sid, payload){
|
||||||
|
if(!sid||!payload) return 0;
|
||||||
|
const q=_getSessionQueue(sid,true);
|
||||||
|
q.push(payload);
|
||||||
|
return q.length;
|
||||||
|
}
|
||||||
|
function shiftQueuedSessionMessage(sid){
|
||||||
|
const q=_getSessionQueue(sid,false);
|
||||||
|
if(!q.length) return null;
|
||||||
|
const next=q.shift();
|
||||||
|
if(!q.length) delete SESSION_QUEUES[sid];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
function getQueuedSessionCount(sid){
|
||||||
|
return _getSessionQueue(sid,false).length;
|
||||||
|
}
|
||||||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
|
||||||
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
||||||
@@ -513,28 +534,37 @@ function setBusy(v){
|
|||||||
setComposerStatus('');
|
setComposerStatus('');
|
||||||
// Always hide Cancel button when not busy
|
// Always hide Cancel button when not busy
|
||||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||||
updateQueueBadge();
|
const sid=S.session&&S.session.session_id;
|
||||||
// Drain one queued message after UI settles
|
updateQueueBadge(sid);
|
||||||
if(MSG_QUEUE.length>0){
|
// Drain one queued message for the currently viewed session after UI settles
|
||||||
const next=MSG_QUEUE.shift();
|
const next=sid?shiftQueuedSessionMessage(sid):null;
|
||||||
updateQueueBadge();
|
if(next){
|
||||||
setTimeout(()=>{ $('msg').value=next; send(); }, 120);
|
updateQueueBadge(sid);
|
||||||
|
setTimeout(()=>{
|
||||||
|
$('msg').value=next.text||'';
|
||||||
|
S.pendingFiles=Array.isArray(next.files)?[...next.files]:[];
|
||||||
|
autoResize();
|
||||||
|
renderTray();
|
||||||
|
send();
|
||||||
|
},120);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateQueueBadge(){
|
function updateQueueBadge(sessionId){
|
||||||
|
const sid=sessionId||(S.session&&S.session.session_id);
|
||||||
|
const count=sid?getQueuedSessionCount(sid):0;
|
||||||
let badge=$('queueBadge');
|
let badge=$('queueBadge');
|
||||||
if(MSG_QUEUE.length>0){
|
if(count>0){
|
||||||
if(!badge){
|
if(!badge){
|
||||||
badge=document.createElement('div');
|
badge=document.createElement('div');
|
||||||
badge.id='queueBadge';
|
badge.id='queueBadge';
|
||||||
badge.style.cssText='position:fixed;bottom:80px;right:24px;background:rgba(124,185,255,.18);border:1px solid rgba(124,185,255,.4);color:var(--blue);font-size:12px;font-weight:600;padding:6px 14px;border-radius:20px;z-index:50;pointer-events:none;backdrop-filter:blur(8px);';
|
badge.style.cssText='position:fixed;bottom:80px;right:24px;background:rgba(124,185,255,.18);border:1px solid rgba(124,185,255,.4);color:var(--blue);font-size:12px;font-weight:600;padding:6px 14px;border-radius:20px;z-index:50;pointer-events:none;backdrop-filter:blur(8px);';
|
||||||
document.body.appendChild(badge);
|
document.body.appendChild(badge);
|
||||||
}
|
}
|
||||||
badge.textContent=MSG_QUEUE.length===1?'1 message queued':`${MSG_QUEUE.length} messages queued`;
|
badge.textContent=count===1?'1 message queued':`${count} messages queued`;
|
||||||
} else {
|
} else if(badge) {
|
||||||
if(badge) badge.remove();
|
badge.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);}
|
function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);}
|
||||||
@@ -714,11 +744,11 @@ async function refreshSession() {
|
|||||||
try {
|
try {
|
||||||
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
|
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
|
||||||
S.session = data.session;
|
S.session = data.session;
|
||||||
S.messages = (data.session.messages || []).filter(m => {
|
S.messages = data.session.messages || [];
|
||||||
if (!m || !m.role || m.role === 'tool') return false;
|
const pendingMsg=getPendingSessionMessage(data.session);
|
||||||
if (m.role === 'assistant') { let c = m.content || ''; if (Array.isArray(c)) c = c.map(p => p.text||'').join(''); return String(c).trim().length > 0; }
|
if(pendingMsg) S.messages.push(pendingMsg);
|
||||||
return true;
|
S.activeStreamId=data.session.active_stream_id||null;
|
||||||
});
|
|
||||||
syncTopbar(); renderMessages();
|
syncTopbar(); renderMessages();
|
||||||
showToast('Conversation refreshed');
|
showToast('Conversation refreshed');
|
||||||
} catch(e) { setStatus('Refresh failed: ' + e.message); }
|
} catch(e) { setStatus('Refresh failed: ' + e.message); }
|
||||||
@@ -764,12 +794,46 @@ async function applyUpdates(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPendingSessionMessage(session){
|
||||||
|
const text=String(session?.pending_user_message||'').trim();
|
||||||
|
if(!text) return null;
|
||||||
|
const attachments=Array.isArray(session?.pending_attachments)?session.pending_attachments.filter(Boolean):[];
|
||||||
|
const messages=Array.isArray(session?.messages)?session.messages:[];
|
||||||
|
const lastUser=[...messages].reverse().find(m=>m&&m.role==='user');
|
||||||
|
if(lastUser){
|
||||||
|
const lastText=String(msgContent(lastUser)||'').trim();
|
||||||
|
if(lastText===text){
|
||||||
|
if(attachments.length&&!lastUser.attachments?.length) lastUser.attachments=attachments;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role:'user',
|
||||||
|
content:text,
|
||||||
|
attachments:attachments.length?attachments:undefined,
|
||||||
|
_ts:session?.pending_started_at||Date.now()/1000,
|
||||||
|
_pending:true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// loadInflightState — retrieve in-memory inflight state for a session.
|
||||||
|
// Called by loadSession() when active_stream_id is set on the server session
|
||||||
|
// but no INFLIGHT[sid] entry exists (e.g. after a session switch back).
|
||||||
|
// Returns the stored state dict or null. The else-path in loadSession handles
|
||||||
|
// page reloads directly via attachLiveStream when this returns null.
|
||||||
|
function loadInflightState(sid, streamId) {
|
||||||
|
// In-memory store: only survives within the same page load.
|
||||||
|
// If INFLIGHT[sid] exists but the caller already checked !INFLIGHT[sid],
|
||||||
|
// this won't be reached. Return null — the else path handles page reloads.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function checkInflightOnBoot(sid) {
|
async function checkInflightOnBoot(sid) {
|
||||||
const raw = localStorage.getItem(INFLIGHT_KEY);
|
const raw = localStorage.getItem(INFLIGHT_KEY);
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
try {
|
try {
|
||||||
const {sid: inflightSid, streamId, ts} = JSON.parse(raw);
|
const {sid: inflightSid, streamId, ts} = JSON.parse(raw);
|
||||||
if (inflightSid !== sid) { clearInflight(); return; }
|
if (inflightSid !== sid) { clearInflight(); return; }
|
||||||
|
if (S.activeStreamId && S.activeStreamId === streamId) return;
|
||||||
// Only show banner if the in-flight entry is less than 10 minutes old
|
// Only show banner if the in-flight entry is less than 10 minutes old
|
||||||
if (Date.now() - ts > 10 * 60 * 1000) { clearInflight(); return; }
|
if (Date.now() - ts > 10 * 60 * 1000) { clearInflight(); return; }
|
||||||
// Check if stream is still active
|
// Check if stream is still active
|
||||||
@@ -915,6 +979,7 @@ function renderMessages(){
|
|||||||
}
|
}
|
||||||
const row=document.createElement('div');row.className='msg-row';
|
const row=document.createElement('div');row.className='msg-row';
|
||||||
row.dataset.msgIdx=rawIdx;row.dataset.role=m.role||'assistant';
|
row.dataset.msgIdx=rawIdx;row.dataset.role=m.role||'assistant';
|
||||||
|
if(m._live) row.setAttribute('data-live-assistant','1');
|
||||||
let filesHtml='';
|
let filesHtml='';
|
||||||
if(m.attachments&&m.attachments.length)
|
if(m.attachments&&m.attachments.length)
|
||||||
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">${li('paperclip',12)} ${esc(f)}</div>`).join('')}</div>`;
|
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">${li('paperclip',12)} ${esc(f)}</div>`).join('')}</div>`;
|
||||||
@@ -1357,12 +1422,29 @@ function renderKatexBlocks(){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendThinking(){
|
function _thinkingMarkup(text=''){
|
||||||
$('emptyState').style.display='none';
|
const _bn=window._botName||'Hermes';
|
||||||
const row=document.createElement('div');row.className='msg-row';row.id='thinkingRow';
|
const icon=esc(_bn.charAt(0).toUpperCase());
|
||||||
row.innerHTML=`<div class="msg-role assistant"><div class="role-icon assistant">H</div>Hermes</div><div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
|
const label=esc(_bn);
|
||||||
$('msgInner').appendChild(row);scrollToBottom();
|
const body=(text&&String(text).trim())
|
||||||
|
? `<div class="thinking-card open"><div class="thinking-card-header"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span></div><div class="thinking-card-body"><pre>${esc(String(text).trim())}</pre></div></div>`
|
||||||
|
: `<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
|
||||||
|
return `<div class="msg-role assistant"><div class="role-icon assistant">${icon}</div>${label}</div>${body}`;
|
||||||
}
|
}
|
||||||
|
function appendThinking(text=''){
|
||||||
|
$('emptyState').style.display='none';
|
||||||
|
let row=$('thinkingRow');
|
||||||
|
if(!row){
|
||||||
|
row=document.createElement('div');
|
||||||
|
row.className='msg-row';
|
||||||
|
row.id='thinkingRow';
|
||||||
|
$('msgInner').appendChild(row);
|
||||||
|
}
|
||||||
|
row.className=(text&&String(text).trim())?'msg-row thinking-card-row':'msg-row';
|
||||||
|
row.innerHTML=_thinkingMarkup(text);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
function updateThinking(text=''){appendThinking(text);}
|
||||||
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
|
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
|
||||||
|
|
||||||
function fileIcon(name, type){
|
function fileIcon(name, type){
|
||||||
|
|||||||
@@ -241,6 +241,24 @@ def test_done_handler_guards_setbusy_with_inflight_check(cleanup_test_sessions):
|
|||||||
assert "INFLIGHT[S.session.session_id]" in src, "messages.js must guard setBusy(false) with INFLIGHT check for current session"
|
assert "INFLIGHT[S.session.session_id]" in src, "messages.js must guard setBusy(false) with INFLIGHT check for current session"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_handler_does_not_drop_tool_messages_needed_by_todos(cleanup_test_sessions):
|
||||||
|
"""Todo panel state must survive session reload/refresh.
|
||||||
|
The UI can hide tool-role messages from the visible transcript, but it must not
|
||||||
|
destroy the raw session messages because loadTodos reconstructs state from the
|
||||||
|
latest todo tool output.
|
||||||
|
"""
|
||||||
|
sessions_src = (REPO_ROOT / "static/sessions.js").read_text()
|
||||||
|
ui_src = (REPO_ROOT / "static/ui.js").read_text()
|
||||||
|
panels_src = (REPO_ROOT / "static/panels.js").read_text()
|
||||||
|
|
||||||
|
assert "data.session.messages=(data.session.messages||[]).filter(" not in sessions_src, \
|
||||||
|
"sessions.js must not overwrite raw session.messages when filtering transcript display"
|
||||||
|
assert "S.messages = (data.session.messages || []).filter(" not in ui_src, \
|
||||||
|
"ui.js refreshSession must not rebuild S.messages by discarding tool messages from the raw session payload"
|
||||||
|
assert "const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;" in panels_src, \
|
||||||
|
"loadTodos must prefer raw S.session.messages so todo state survives reloads"
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_button_not_cleared_across_sessions(cleanup_test_sessions):
|
def test_cancel_button_not_cleared_across_sessions(cleanup_test_sessions):
|
||||||
"""R7c: The Cancel button and activeStreamId must only be cleared when the
|
"""R7c: The Cancel button and activeStreamId must only be cleared when the
|
||||||
done/error event belongs to the currently viewed session.
|
done/error event belongs to the currently viewed session.
|
||||||
@@ -440,7 +458,166 @@ def test_newSession_clears_live_tool_cards(cleanup_test_sessions):
|
|||||||
assert "clearLiveToolCards" in new_sess_body, "newSession() must call clearLiveToolCards() to clear stale live cards"
|
assert "clearLiveToolCards" in new_sess_body, "newSession() must call clearLiveToolCards() to clear stale live cards"
|
||||||
|
|
||||||
|
|
||||||
# ── R16: Stack traces must not leak to clients in 500 responses ────────────
|
def test_newSession_resets_busy_state_for_fresh_chat(cleanup_test_sessions):
|
||||||
|
"""R15b: newSession() must reset the viewed chat to idle state.
|
||||||
|
Without this, starting a second chat while another session is streaming leaves
|
||||||
|
S.busy=true, so the first send in the new chat gets incorrectly queued.
|
||||||
|
"""
|
||||||
|
src = (REPO_ROOT / "static/sessions.js").read_text()
|
||||||
|
new_sess_idx = src.find("async function newSession(")
|
||||||
|
assert new_sess_idx >= 0
|
||||||
|
next_fn = src.find("async function ", new_sess_idx + 10)
|
||||||
|
new_sess_body = src[new_sess_idx:next_fn]
|
||||||
|
assert "S.busy=false;" in new_sess_body, \
|
||||||
|
"newSession() must clear S.busy so a fresh chat is immediately sendable"
|
||||||
|
assert "S.activeStreamId=null;" in new_sess_body, \
|
||||||
|
"newSession() must clear the active stream id for the newly viewed chat"
|
||||||
|
assert "updateQueueBadge(S.session.session_id);" in new_sess_body, \
|
||||||
|
"newSession() must refresh the badge for the new session rather than leaving the old session's queue badge visible"
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_scoped_message_queue_frontend_wiring(cleanup_test_sessions):
|
||||||
|
"""R15bb: queued follow-ups must stay attached to their originating session.
|
||||||
|
The frontend should use a session-keyed queue store and drain only the active
|
||||||
|
session's queued messages when that session becomes idle.
|
||||||
|
"""
|
||||||
|
ui_src = (REPO_ROOT / "static/ui.js").read_text()
|
||||||
|
messages_src = (REPO_ROOT / "static/messages.js").read_text()
|
||||||
|
sessions_src = (REPO_ROOT / "static/sessions.js").read_text()
|
||||||
|
assert "const SESSION_QUEUES" in ui_src
|
||||||
|
assert "function queueSessionMessage" in ui_src
|
||||||
|
assert "function shiftQueuedSessionMessage" in ui_src
|
||||||
|
assert "const sid=S.session&&S.session.session_id;" in ui_src
|
||||||
|
assert "const next=sid?shiftQueuedSessionMessage(sid):null;" in ui_src
|
||||||
|
assert "queueSessionMessage(S.session.session_id" in messages_src
|
||||||
|
assert "updateQueueBadge(S.session.session_id);" in messages_src
|
||||||
|
assert "updateQueueBadge(sid);" in sessions_src
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_start_persists_pending_turn_metadata_for_reload_recovery(cleanup_test_sessions):
|
||||||
|
"""R15c: chat/start must expose enough pending-turn metadata for a reload to
|
||||||
|
rebuild the in-flight conversation instead of showing a blank session.
|
||||||
|
"""
|
||||||
|
routes_src = (REPO_ROOT / "api/routes.py").read_text()
|
||||||
|
assert 's.active_stream_id = stream_id' in routes_src
|
||||||
|
assert 's.pending_user_message = msg' in routes_src
|
||||||
|
assert 's.pending_attachments = attachments' in routes_src
|
||||||
|
assert '"active_stream_id": getattr(s, "active_stream_id", None)' in routes_src
|
||||||
|
assert '"pending_user_message": getattr(s, "pending_user_message", None)' in routes_src
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_path_restores_pending_message_and_reattaches_live_stream(cleanup_test_sessions):
|
||||||
|
"""R15d: the frontend reload path must show the pending user turn and
|
||||||
|
reattach to the live SSE stream after loadSession().
|
||||||
|
"""
|
||||||
|
sessions_src = (REPO_ROOT / "static/sessions.js").read_text()
|
||||||
|
ui_src = (REPO_ROOT / "static/ui.js").read_text()
|
||||||
|
messages_src = (REPO_ROOT / "static/messages.js").read_text()
|
||||||
|
assert 'getPendingSessionMessage' in ui_src
|
||||||
|
assert 'pending_user_message' in ui_src
|
||||||
|
assert 'function attachLiveStream' in messages_src
|
||||||
|
assert 'const pendingMsg=typeof getPendingSessionMessage' in sessions_src
|
||||||
|
assert 'const activeStreamId=data.session.active_stream_id||null;' in sessions_src
|
||||||
|
assert 'attachLiveStream(sid, activeStreamId' in sessions_src
|
||||||
|
assert 'if (S.activeStreamId && S.activeStreamId === streamId) return;' in ui_src
|
||||||
|
|
||||||
|
|
||||||
|
# ── R16: Switching away/back must preserve live partial assistant output ─────
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_stream_tokens_persist_partial_assistant_for_session_switch(cleanup_test_sessions):
|
||||||
|
"""R16: in-flight assistant text must be mirrored into INFLIGHT session state,
|
||||||
|
and the live stream must rebind to the rebuilt DOM after switching away and back.
|
||||||
|
Without this, partial assistant output disappears until the final done payload lands.
|
||||||
|
"""
|
||||||
|
messages_src = (REPO_ROOT / "static/messages.js").read_text()
|
||||||
|
ui_src = (REPO_ROOT / "static/ui.js").read_text()
|
||||||
|
|
||||||
|
assert "content:assistantText" in messages_src, \
|
||||||
|
"messages.js must persist the partial assistant text into INFLIGHT state"
|
||||||
|
assert "_live:true" in messages_src, \
|
||||||
|
"messages.js must mark the persisted in-flight assistant row so renderMessages can re-anchor it"
|
||||||
|
assert "syncInflightAssistantMessage();" in messages_src, \
|
||||||
|
"token handler must update INFLIGHT state before checking the active session"
|
||||||
|
assert "assistantRow&&!assistantRow.isConnected" in messages_src, \
|
||||||
|
"live stream must drop stale detached assistant DOM references after session switches"
|
||||||
|
assert "data-live-assistant" in ui_src, \
|
||||||
|
"renderMessages must preserve a live-assistant DOM anchor when rebuilding the thread"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflight_session_state_tracks_live_tool_cards_per_session(cleanup_test_sessions):
|
||||||
|
"""R16b: live tool cards must be stored on the in-flight session, not only in the
|
||||||
|
global S.toolCalls array, so switching chats does not lose or misattach them.
|
||||||
|
"""
|
||||||
|
messages_src = (REPO_ROOT / "static/messages.js").read_text()
|
||||||
|
sessions_src = (REPO_ROOT / "static/sessions.js").read_text()
|
||||||
|
|
||||||
|
assert "INFLIGHT[activeSid].toolCalls.push(tc);" in messages_src, \
|
||||||
|
"tool SSE handler must persist live tool calls onto the in-flight session"
|
||||||
|
assert "S.toolCalls=(INFLIGHT[sid].toolCalls||[]);" in sessions_src, \
|
||||||
|
"loadSession() must restore live tool calls from the in-flight session state"
|
||||||
|
|
||||||
|
|
||||||
|
def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessions):
|
||||||
|
"""R16c: loading an in-flight session must mark it busy before renderMessages().
|
||||||
|
Otherwise renderMessages() treats S.toolCalls as settled history cards and the
|
||||||
|
same tool call appears once inline and once in the live tool host after a
|
||||||
|
session switch.
|
||||||
|
"""
|
||||||
|
src = (REPO_ROOT / "static/sessions.js").read_text()
|
||||||
|
inflight_idx = src.find("if(INFLIGHT[sid]){")
|
||||||
|
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
|
||||||
|
inflight_block = src[inflight_idx:inflight_idx+700]
|
||||||
|
busy_pos = inflight_block.find("S.busy=true;")
|
||||||
|
render_pos = inflight_block.find("renderMessages();appendThinking();")
|
||||||
|
assert busy_pos >= 0, "loadSession INFLIGHT branch must set S.busy=true"
|
||||||
|
assert render_pos >= 0, "loadSession INFLIGHT branch must call renderMessages()"
|
||||||
|
assert busy_pos < render_pos, \
|
||||||
|
"loadSession must set S.busy=true before renderMessages() to avoid duplicate tool cards"
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_bridge_accepts_current_tool_progress_callback_signature(cleanup_test_sessions):
|
||||||
|
"""R17: api/streaming.py must accept the current Hermes agent callback contract.
|
||||||
|
The agent now calls tool_progress_callback(event_type, name, preview, args, **kwargs).
|
||||||
|
If the WebUI bridge only accepts (name, preview, args), live tool updates silently vanish.
|
||||||
|
"""
|
||||||
|
src = (REPO_ROOT / "api/streaming.py").read_text()
|
||||||
|
assert "def on_tool(*cb_args, **cb_kwargs):" in src, \
|
||||||
|
"streaming.py must accept variable callback args for tool progress events"
|
||||||
|
assert "reasoning_callback=on_reasoning" in src, \
|
||||||
|
"streaming.py must wire the agent's reasoning callback into the SSE bridge"
|
||||||
|
assert "put('tool_complete'" in src or 'put("tool_complete"' in src, \
|
||||||
|
"streaming.py must emit live tool completion SSE events"
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_sessions):
|
||||||
|
"""R18: messages.js must render live reasoning and react to tool completion events.
|
||||||
|
Without these handlers, the operator only sees generic Thinking… or nothing
|
||||||
|
until the final done snapshot redraws the whole turn.
|
||||||
|
"""
|
||||||
|
src = (REPO_ROOT / "static/messages.js").read_text()
|
||||||
|
assert "let reasoningText=''" in src, \
|
||||||
|
"messages.js must track streamed reasoning text separately from assistant text"
|
||||||
|
assert "source.addEventListener('reasoning'" in src or 'source.addEventListener("reasoning"' in src, \
|
||||||
|
"messages.js must listen for live reasoning SSE events"
|
||||||
|
assert "source.addEventListener('tool_complete'" in src or 'source.addEventListener("tool_complete"' in src, \
|
||||||
|
"messages.js must listen for live tool completion SSE events"
|
||||||
|
assert "function _parseStreamState()" in src, \
|
||||||
|
"messages.js must parse live stream state into reasoning + visible answer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_js_can_upgrade_thinking_spinner_into_live_reasoning_card(cleanup_test_sessions):
|
||||||
|
"""R19: ui.js must be able to replace the placeholder thinking spinner with
|
||||||
|
streamed reasoning text while a turn is in progress.
|
||||||
|
"""
|
||||||
|
src = (REPO_ROOT / "static/ui.js").read_text()
|
||||||
|
assert "function _thinkingMarkup(text='')" in src or 'function _thinkingMarkup(text="")' in src, \
|
||||||
|
"ui.js must centralize thinking row markup so it can switch between spinner and live text"
|
||||||
|
assert "function updateThinking(text=''){appendThinking(text);}" in src or 'function updateThinking(text=""){appendThinking(text);}' in src, \
|
||||||
|
"ui.js must expose an updateThinking helper for live reasoning rendering"
|
||||||
|
|
||||||
|
|
||||||
|
# ── R17: Stack traces must not leak to clients in 500 responses ────────────
|
||||||
|
|
||||||
def test_500_response_has_no_trace_field():
|
def test_500_response_has_no_trace_field():
|
||||||
"""R16: HTTP 500 responses must not include a 'trace' field.
|
"""R16: HTTP 500 responses must not include a 'trace' field.
|
||||||
|
|||||||
Reference in New Issue
Block a user