From 8b8ee92eca98e28efe834a9ffe3092d18919f6a4 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 1 Jul 2026 18:06:21 +0000 Subject: [PATCH] fix(webui): overlay real state.db message count for subagent children so they don't vanish from the sidebar (#5308) Regression seam behind #5308: a delegated subagent child's sidebar row is built from a stale sidecar that reports message_count==0, and the state.db count overlay in _apply_sidebar_state_db_override_metadata was gated on `state_db_source == 'webui'`. A subagent child (state_db_source=='subagent') therefore never received its true message count, so the front-end visibility predicate (_sidebarRowHasVisibleMessages) dropped the row and the subagent session disappeared entirely (not nested, not orphaned) after #5244+#5306. Fix: widen the count/last-message overlay to `state_db_source in ('webui','subagent')`, keeping the same conservative anti-resurrection guard. The source-tag/title reassignment stays WebUI-only so a subagent child keeps its subagent classification. Same state.db-blind-metadata root as the #5307 transcript recovery, fixed server-side rather than by loosening the front-end predicate (which would fight the #5306 active-parent scoping). Tests: subagent child gets its count overlaid + classification preserved; a non-webui/non-subagent foreign source (cron) still gets NO overlay. Fixes #5308. --- api/models.py | 14 ++++ tests/test_webui_state_db_reconciliation.py | 82 +++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/api/models.py b/api/models.py index 060b2b11..9e5ab1b9 100644 --- a/api/models.py +++ b/api/models.py @@ -4028,6 +4028,20 @@ def _apply_sidebar_state_db_override_metadata(sessions: list[dict], metadata: di session['session_source'] = state_db_session_source session['source_label'] = state_db_source_label session['is_cli_session'] = False + # Overlay the real state.db message count for WebUI-owned rows AND for + # delegated subagent children (#5308). A subagent child + # (state_db_source == 'subagent') is backed by the delegate runner's + # state.db session, but its sidebar row is built from a stale sidecar + # that often reports message_count == 0. Without overlaying the true + # count, the front-end visibility predicate + # (_sidebarRowHasVisibleMessages) drops the row and the subagent + # session vanishes from the sidebar entirely (regression seam behind + # #5308, same state.db-blind-metadata root as the #5307 transcript + # recovery). The count overlay keeps the same conservative + # anti-resurrection guard used for WebUI rows. The source-tag / title + # reassignment above stays WebUI-only — a subagent child keeps its + # subagent classification. + if state_db_source in ('webui', 'subagent'): try: current_count = max(0, int(session.get('message_count') or 0)) state_count = max(0, int(state_db_message_count or 0)) diff --git a/tests/test_webui_state_db_reconciliation.py b/tests/test_webui_state_db_reconciliation.py index 441e1a31..3e2f1175 100644 --- a/tests/test_webui_state_db_reconciliation.py +++ b/tests/test_webui_state_db_reconciliation.py @@ -155,6 +155,88 @@ def test_sidebar_state_db_overlay_preserves_numeric_actual_count(): assert sessions[0]["actual_message_count"] == 5 +def test_sidebar_state_db_overlay_counts_subagent_child_5308(): + """#5308: a delegated subagent child whose stale sidecar reports + message_count == 0 must receive its real state.db message count so the + front-end sidebar visibility predicate does not drop the row (the subagent + session vanishing regression). The overlay applies to source == 'subagent' + just like 'webui', while the subagent source classification is preserved. + """ + import api.models as models + + sid = "subagent_child_5308" + sessions = [ + { + "session_id": sid, + "source_tag": "subagent", + "raw_source": "subagent", + "session_source": "other", + "relationship_type": "child_session", + "parent_session_id": "parent_abc", + "message_count": 0, + "actual_message_count": 0, + "last_message_at": 0, + "updated_at": 0, + } + ] + + models._apply_sidebar_state_db_override_metadata( + sessions, + { + sid: { + "_state_db_source": "subagent", + "_state_db_message_count": 6, + "_state_db_last_message_at": 2002.0, + } + }, + ) + + # Real state.db count is now overlaid (was 0) -> row survives the sidebar + # visibility predicate instead of vanishing. + assert sessions[0]["message_count"] == 6 + assert sessions[0]["actual_message_count"] == 6 + assert sessions[0]["last_message_at"] == 2002.0 + # Subagent classification is preserved (source-tag reassignment stays + # WebUI-only) — the child does not get re-tagged as a webui session. + assert sessions[0]["source_tag"] == "subagent" + assert sessions[0].get("is_cli_session") is not False + + +def test_sidebar_state_db_overlay_does_not_count_foreign_cli_source_5308(): + """Guard the #5308 overlay scope: a non-webui, non-subagent foreign source + (e.g. a messaging/cron/tui CLI row) must NOT get the count overlay — only + WebUI-owned rows and delegated subagent children do. + """ + import api.models as models + + sid = "cron_row_5308" + sessions = [ + { + "session_id": sid, + "source_tag": "cron", + "message_count": 0, + "actual_message_count": 0, + "last_message_at": 0, + "updated_at": 0, + } + ] + + models._apply_sidebar_state_db_override_metadata( + sessions, + { + sid: { + "_state_db_source": "cron", + "_state_db_message_count": 9, + "_state_db_last_message_at": 3003.0, + } + }, + ) + + # cron is neither webui nor subagent -> count overlay must NOT apply. + assert sessions[0]["message_count"] == 0 + assert sessions[0]["actual_message_count"] == 0 + + def test_tail_cancelled_partial_blocks_state_db_replay(): from api.models import merge_session_messages_append_only