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:
nesquena-hermes
2026-04-13 16:18:15 -07:00
committed by GitHub
parent bcdd7ed3f3
commit 9542639a90
9 changed files with 609 additions and 73 deletions

View File

@@ -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):

View File

@@ -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

View File

@@ -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