Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@

## [Unreleased]

## [v0.51.122] — 2026-05-24 — Release CT (stage-batch4 — 4-PR low-risk batch — stale cache tail / inflight UI / segment flush / reasoning accumulator)

### Fixed

- **PR #2802** by @ai-ag2026 — Drop stale inactive cached user tails when `/api/session` reloads a conversation whose saved sidecar already ends on an assistant answer. Supersedes #2733 (held due to async-compression interaction): the new guard adds a `len(cached_messages) <= len(disk_messages)` filter so it never fires when the cache has genuine new concurrent edits beyond the disk state — only when the cache has an unsaved user row past the saved assistant tail. Adds `api/models._inactive_cache_tail_needs_disk_check()` + `_cache_has_stale_unsaved_user_tail()` helpers and 5 new tests in `tests/test_webui_state_db_reconciliation.py`. Previously-held test `test_session_compress_async_reports_stale_session_guard` now passes (verified). Closes umbrella #2361 partially.

- **PR #2796** by @ai-ag2026 — Clear stale inflight UI state before starting a new send so blocked composer busy-state from failed/incomplete prior turns doesn't divert new turns into the invisible queue. Five-commit squashed fix: (1) drop stale optimistic sidebar rows once canonical session data arrives, (2) clear stale busy state before send via `_clearStaleBusyStateBeforeSend()`, (3) preserve server idle rows over stale optimistic local rows, (4) let `/api/chat/start` survive non-fatal pre-start UI errors via `_runOptionalPreStartUiStep()`, (5) keep those warnings console-only instead of throwing. Adds `_shouldKeepLocalOnlyOptimisticSessionRow()` in `static/sessions.js` and 8 new tests in `tests/test_inflight_send_start_race.py`. Closes #2795. Authorship preserved via `--author`.

- **PR #2777** by @b3nw — Flush pending render before segment reset at tool/interim_assistant boundaries so live tokens that arrived in the 66ms rAF throttle window don't get lost from the DOM when `_resetAssistantSegment()` clears `assistantBody`. New `_flushPendingSegmentRender()` helper writes via `smd`, `renderMd`, or `esc` fallback (same paths as `_doRender`) only when `_renderPending` is true. Completed transcripts were never affected — `renderMessages` rebuilds from the full `assistantText` accumulator on `done`. Adds `tests/test_issue2713_streaming_segment_flush.py`. Closes #2713.

- **PR #2778** by @b3nw — Reset reasoning accumulator per turn and prefer `reasoning_content` over `reasoning` on read. Two related bugs: (1) `reasoningText` was initialized once when the SSE stream opened and never reset between turns, so the `done` event would assign the union of every turn's reasoning to the last assistant message in multi-turn agent sessions; now reset at both turn boundaries (`tool` + `interim_assistant`). (2) `static/ui.js renderMessages` preferred `m.reasoning` (potentially corrupted by bug 1) over `m.reasoning_content` (the clean per-turn backend value); the fallback now reads `m.reasoning_content || m.reasoning`. Updates `tests/test_streaming_race_fix.py` to scope the reconnect-accumulator guard to the `_wireSSE` preamble only (turn-boundary resets inside event listeners are intentional). Adds `tests/test_issue2565_reasoning_accumulation.py`. Closes #2565.

## [v0.51.121] — 2026-05-24 — Release CS (stage-batch3 — 4-PR low-risk batch — state.db merge / display counts / compression marker / Windows launcher)

### Fixed
Expand Down
96 changes: 96 additions & 0 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1723,6 +1723,89 @@ def _repair_stale_pending(session) -> bool:
return False


def _last_non_tool_role(messages) -> str:
if not isinstance(messages, list):
return ''
for message in reversed(messages):
role = _message_role(message)
if role and role != 'tool':
return role
return ''


def _last_non_tool_message(messages):
if not isinstance(messages, list):
return None
for message in reversed(messages):
role = _message_role(message)
if role and role != 'tool':
return message
return None


def _message_content_text(message) -> str:
if not isinstance(message, dict):
return ''
content = message.get('content')
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict) and isinstance(item.get('text'), str):
parts.append(item['text'])
return ''.join(parts)
return ''


def _inactive_cache_tail_needs_disk_check(cached) -> bool:
if cached is None:
return False
if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None):
return False
return _last_non_tool_role(getattr(cached, 'messages', None) or []) == 'user'


def _cache_has_stale_unsaved_user_tail(cached, disk_session) -> bool:
"""Return True when an inactive cached session has an unsaved user tail.

A completed turn is saved to the sidecar before the browser reloads it. In
rare compaction/reconnect paths the in-process cache can retain a recovered
or optimistic user row after the saved assistant tail even though the row was
never persisted. If /api/session serves that cache entry, the visible
transcript appears to end on the old prompt and the saved assistant answer
looks missing until a fork/reload resets the cache.
"""
if cached is None or disk_session is None:
return False
if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None):
return False
cached_messages = getattr(cached, 'messages', None) or []
disk_messages = getattr(disk_session, 'messages', None) or []
if len(cached_messages) <= len(disk_messages):
return False
if _last_non_tool_role(cached_messages) != 'user':
return False
if _last_non_tool_role(disk_messages) != 'assistant':
return False

cached_tail = _last_non_tool_message(cached_messages)
previous_disk_user = None
for message in reversed(disk_messages):
if _message_role(message) == 'user':
previous_disk_user = message
break
if previous_disk_user is None:
return False

# Only drop tails that look like a duplicated optimistic/recovered user row.
# A genuinely new concurrent user edit must stay in memory so stale-session
# guards can report and preserve it.
return _message_content_text(cached_tail) == _message_content_text(previous_disk_user)


def get_session(sid, metadata_only=False):
"""Load a session, optionally with metadata only (skipping the messages array).

Expand All @@ -1736,6 +1819,19 @@ def get_session(sid, metadata_only=False):
if cached is not None:
SESSIONS.move_to_end(sid) # LRU: mark as recently used
if cached is not None:
if not metadata_only and _inactive_cache_tail_needs_disk_check(cached):
try:
disk_session = Session.load(sid)
if _cache_has_stale_unsaved_user_tail(cached, disk_session):
with LOCK:
SESSIONS[sid] = disk_session
SESSIONS.move_to_end(sid)
cached = disk_session
except Exception:
logger.debug(
"stale cached user-tail check failed for session %s",
sid, exc_info=True,
)
if not metadata_only and _session_has_pending_journal_retry(cached):
try:
_try_retry_journal_recovery_in_place(cached)
Expand Down
147 changes: 116 additions & 31 deletions static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,42 @@ let _sendInProgress = false;
let _sendInProgressSid = null; // session_id of the in-flight send
const _sessionTitleProvisionalBySid = new Map();

function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){
if(!S||!S.busy||compressionRunning) return false;
const session=S.session||{};
const sid=session.session_id||'';
const hasRuntimeConfirmation=Boolean(
S.activeStreamId||
session.active_stream_id||
session.pending_user_message||
session.pending_started_at
);
if(hasRuntimeConfirmation) return false;
if(typeof INFLIGHT==='object'&&INFLIGHT&&sid&&INFLIGHT[sid]){
delete INFLIGHT[sid];
if(typeof clearInflightState==='function') clearInflightState(sid);
}
S.activeStreamId=null;
if(session) session.active_stream_id=null;
if(typeof setBusy==='function') setBusy(false);
else S.busy=false;
if(typeof setComposerStatus==='function') setComposerStatus('');
if(typeof setStatus==='function') setStatus('');
if(typeof updateSendBtn==='function') updateSendBtn();
if(sid&&typeof clearOptimisticSessionStreaming==='function') clearOptimisticSessionStreaming(sid);
return true;
}

function _runOptionalPreStartUiStep(label, fn){
try{
return typeof fn==='function'?fn():undefined;
}catch(e){
const message=e&&e.message?e.message:String(e||'unknown error');
try{console.warn('[webui] optional pre-start UI step failed', label, message);}catch(_){ }
return undefined;
}
}

function _sessionTitleLooksDefaultOrProvisional(titleText, provisionalText){
const title=String(titleText||'').replace(/\s+/g,' ').trim();
if(!title||title==='Untitled'||title==='New Chat')return true;
Expand Down Expand Up @@ -262,6 +298,7 @@ async function send(){
}

const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning();
_clearStaleBusyStateBeforeSend({compressionRunning});
// If busy or a manual compression is still running, handle based on busy_input_mode
if(S.busy||compressionRunning){
if(text){
Expand Down Expand Up @@ -409,39 +446,68 @@ async function send(){
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000};
S.toolCalls=[]; // clear tool calls from previous turn
clearLiveToolCards(); // clear any leftover live cards from last turn
S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);
// First optimistic pass: make the local user turn visible before /api/chat/start
// can save pending state on the server.
if(typeof upsertActiveSessionForLocalTurn==='function'){
upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
}
const optimisticMessages=[...S.messages];
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]});
}
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
startApprovalPolling(activeSid);
startClarifyPolling(activeSid);
_fetchYoloState(activeSid); // sync YOLO pill with backend state
S.activeStreamId = null; // will be set after stream starts
if(typeof updateSendBtn==='function') updateSendBtn();
let optimisticMessages;
try{
S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);
// First optimistic pass: make the local user turn visible before /api/chat/start
// can save pending state on the server.
_runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.initial', ()=>{
if(typeof upsertActiveSessionForLocalTurn==='function'){
upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
}
});
optimisticMessages=[...S.messages];
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]});
}
_runOptionalPreStartUiStep('renderSessionListFromCache.initial', ()=>{
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
});
_runOptionalPreStartUiStep('startApprovalPolling.prestart', ()=>startApprovalPolling(activeSid));
_runOptionalPreStartUiStep('startClarifyPolling.prestart', ()=>startClarifyPolling(activeSid));
_runOptionalPreStartUiStep('fetchYoloState.prestart', ()=>_fetchYoloState(activeSid)); // sync YOLO pill with backend state
S.activeStreamId = null; // will be set after stream starts
_runOptionalPreStartUiStep('updateSendBtn.prestart', ()=>{
if(typeof updateSendBtn==='function') updateSendBtn();
});

// Set provisional title from user message immediately so session appears
// in the sidebar right away with a meaningful name. /api/chat/start persists
// the server-side provisional title and may refine this optimistic text.
if(S.session&&(S.session.title==='Untitled'||!S.session.title)){
const provisionalTitle=displayText.slice(0,64);
applySessionTitleUpdate(activeSid, provisionalTitle, {force:true, rememberProvisional:true});
if(typeof upsertActiveSessionForLocalTurn==='function'){
// Second optimistic pass: carry the provisional title into the cached row
// without re-fetching /api/sessions before pending state exists server-side.
upsertActiveSessionForLocalTurn({title:provisionalTitle,messageCount:S.messages.length,timestampMs:Date.now()});
// Set provisional title from user message immediately so session appears
// in the sidebar right away with a meaningful name. /api/chat/start persists
// the server-side provisional title and may refine this optimistic text.
if(S.session&&(S.session.title==='Untitled'||!S.session.title)){
const provisionalTitle=displayText.slice(0,64);
_runOptionalPreStartUiStep('applySessionTitleUpdate.provisional', ()=>{
applySessionTitleUpdate(activeSid, provisionalTitle, {force:true, rememberProvisional:true});
});
_runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.provisional', ()=>{
if(typeof upsertActiveSessionForLocalTurn==='function'){
// Second optimistic pass: carry the provisional title into the cached row
// without re-fetching /api/sessions before pending state exists server-side.
upsertActiveSessionForLocalTurn({title:provisionalTitle,messageCount:S.messages.length,timestampMs:Date.now()});
}
});
} else if(typeof upsertActiveSessionForLocalTurn==='function'){
_runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.titled', ()=>{
upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
});
} else {
_runOptionalPreStartUiStep('renderSessionListFromCache.prestart', ()=>{
renderSessionListFromCache(); // ensure it's visible even if already titled
});
}
} else if(typeof upsertActiveSessionForLocalTurn==='function'){
upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
} else {
renderSessionListFromCache(); // ensure it's visible even if already titled
}catch(preStartError){
// The user turn must reach /api/chat/start even if local optimistic UI
// bookkeeping (render cache, storage quota, sidebar reconciliation, etc.)
// throws. Otherwise the pane can show a user bubble + spinner while the
// backend never receives the turn.
const message=preStartError&&preStartError.message?preStartError.message:String(preStartError||'unknown error');
try{console.warn('[webui] pre-start optimistic UI failed; continuing to /api/chat/start', message);}catch(_){ }
if(!S.messages.includes(userMsg)) S.messages.push(userMsg);
optimisticMessages=[...S.messages];
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
try{setBusy(true);}catch(_){S.busy=true;}
S.activeStreamId=null;
}

// Start the agent via POST, get a stream_id back
Expand Down Expand Up @@ -1285,6 +1351,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
};
step();
}
function _flushPendingSegmentRender(){
if(!assistantBody||!_renderPending) return;
_cancelAnimationFramePendingStreamRender();
const displayText=segmentStart===0
? _parseStreamState().displayText
: _stripXmlToolCalls(assistantText.slice(segmentStart));
if(_smdParser){
_smdWrite(displayText);
} else if(renderMd){
assistantBody.innerHTML=renderMd(displayText);
} else {
assistantBody.innerHTML=esc(displayText);
}
}
function _resetAssistantSegment(){
assistantRow=null;
assistantBody=null;
Expand Down Expand Up @@ -1410,6 +1490,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!visible){
return;
}
reasoningText='';
liveReasoningText='';
if(alreadyStreamed){
if(!S.session||S.session.session_id!==activeSid) return;
_resetAssistantSegment();
Expand All @@ -1423,6 +1505,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(typeof updateThinking==='function') updateThinking(_liveThinkingText());
else appendThinking(_liveThinkingText());
}
_flushPendingSegmentRender();
ensureAssistantRow(true);
_resetAssistantSegment();
_scheduleRender();
Expand Down Expand Up @@ -1467,12 +1550,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// to be re-created below everything when reasoning resumed post-tool.
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
liveReasoningText='';
reasoningText='';
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
appendLiveToolCard(tc);
snapshotLiveTurn();
// Reset the live assistant row reference so that any text tokens arriving
// after this tool call create a NEW segment appended below the tool card,
// rather than updating the old segment that sits above it in the DOM.
_flushPendingSegmentRender();
_freshSegment=true;
_smdEndParser();
_resetAssistantSegment();
Expand Down
Loading
Loading