Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

## [Unreleased]

### Fixed

- Drop stale inactive cached user tails when `/api/session` reloads a conversation whose saved sidecar already ends on an assistant answer. This prevents compacted conversations from appearing to hide the final assistant response until the user forks/reloads the session.

## [v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored)

### 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
45 changes: 45 additions & 0 deletions tests/test_webui_state_db_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,51 @@ def test_metadata_fast_path_excludes_state_db_rows_filtered_by_reconciliation(mo
assert session["last_message_at"] == 1001.0


def test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant(monkeypatch, tmp_path):
import api.models as models
import api.routes as routes

sid = "webui_reconcile_cached_user_tail"
_install_test_session(
monkeypatch,
tmp_path,
sid,
[
{"role": "user", "content": "please audit phase c", "timestamp": 1000.0},
{"role": "assistant", "content": "final audit complete", "timestamp": 1001.0},
],
)
_make_state_db(
tmp_path / "state.db",
sid,
[
{"role": "user", "content": "please audit phase c", "timestamp": 1000.0},
{"role": "assistant", "content": "final audit complete", "timestamp": 1001.0},
],
)

cached = models.Session.load(sid)
cached.messages.append(
{
"role": "user",
"content": "please audit phase c",
"timestamp": 1002.0,
}
)
cached.pending_user_message = None
cached.active_stream_id = None
models.SESSIONS[sid] = cached

handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0")
routes.handle_get(handler, urlparse(handler.path))

assert handler.status == 200
messages = handler.response_json["session"]["messages"]
assert messages[-1]["role"] == "assistant"
assert messages[-1]["content"] == "final audit complete"
assert handler.response_json["session"]["message_count"] == 2


def test_state_db_reconciliation_preserves_tool_metadata(monkeypatch, tmp_path):
import api.routes as routes

Expand Down
Loading