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:
@@ -44,6 +44,10 @@ class Session:
|
||||
project_id: str=None, profile=None,
|
||||
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
|
||||
personality=None,
|
||||
active_stream_id: str=None,
|
||||
pending_user_message: str=None,
|
||||
pending_attachments=None,
|
||||
pending_started_at=None,
|
||||
**kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
self.title = title
|
||||
@@ -61,6 +65,10 @@ class Session:
|
||||
self.output_tokens = output_tokens or 0
|
||||
self.estimated_cost = estimated_cost
|
||||
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
|
||||
def path(self):
|
||||
|
||||
@@ -365,6 +365,10 @@ def handle_get(handler, parsed) -> bool:
|
||||
raw = s.compact() | {
|
||||
"messages": s.messages,
|
||||
"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)})
|
||||
except KeyError:
|
||||
@@ -1683,11 +1687,15 @@ def _handle_chat_start(handler, body):
|
||||
attachments = [str(a) for a in (body.get("attachments") or [])][:20]
|
||||
workspace = str(Path(body.get("workspace") or s.workspace).expanduser().resolve())
|
||||
model = body.get("model") or s.model
|
||||
stream_id = uuid.uuid4().hex
|
||||
s.workspace = workspace
|
||||
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()
|
||||
set_last_workspace(workspace)
|
||||
stream_id = uuid.uuid4().hex
|
||||
q = queue.Queue()
|
||||
with STREAMS_LOCK:
|
||||
STREAMS[stream_id] = q
|
||||
|
||||
@@ -172,23 +172,70 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
_token_sent = True
|
||||
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 = {}
|
||||
if isinstance(args, dict):
|
||||
for k, v in list(args.items())[:4]:
|
||||
s2 = str(v); args_snap[k] = s2[:120]+('...' if len(s2)>120 else '')
|
||||
put('tool', {'name': name, 'preview': preview, 'args': args_snap})
|
||||
# Fallback: poll for pending approval in case notify_cb wasn't
|
||||
# registered (e.g. older approval module without gateway support).
|
||||
try:
|
||||
from tools.approval import has_pending as _has_pending, _pending, _lock
|
||||
if _has_pending(session_id):
|
||||
with _lock:
|
||||
p = dict(_pending.get(session_id, {}))
|
||||
if p:
|
||||
put('approval', p)
|
||||
except ImportError:
|
||||
pass
|
||||
s2 = str(v)
|
||||
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
|
||||
# registered (e.g. older approval module without gateway support).
|
||||
try:
|
||||
from tools.approval import has_pending as _has_pending, _pending, _lock
|
||||
if _has_pending(session_id):
|
||||
with _lock:
|
||||
p = dict(_pending.get(session_id, {}))
|
||||
if p:
|
||||
put('approval', p)
|
||||
except ImportError:
|
||||
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()
|
||||
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_db=_session_db,
|
||||
stream_delta_callback=on_token,
|
||||
reasoning_callback=on_reasoning,
|
||||
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,
|
||||
})
|
||||
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
|
||||
# Only tag a user message whose content relates to this turn's text
|
||||
# (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:
|
||||
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)
|
||||
# Detect rate limit errors specifically so the client can show a helpful card
|
||||
# rather than the generic "Connection lost" message
|
||||
|
||||
Reference in New Issue
Block a user