fix(stapp): own stream bubble inside fragment for cross-version rerun safety

This commit is contained in:
Liang Jiaqing committed 2026-08-21 14:21:27 +08:00
1 parent b0d270394d
commit 30b24ad31d
1 file changed
+13 -17
+13 -17
View File
@@ -434,20 +434,20 @@ if prompt:
with st.chat_message("user"): st.markdown(prompt)
_start_main_task(prompt)
# Stream hosts only when this session owns the queue.
# Poll quickly while active; reduce idle renderer churn.
_stream_fh = None
if st.session_state.get('display_queue') is not None:
with st.chat_message("assistant"):
_stream_fh = st.container()
elif agent.is_running:
# Stream bubble is owned by the fragment below: a fragment atomically replaces
# its *own* subtree on every rerun in all Streamlit versions, whereas writing
# into a container created outside the fragment is version-dependent (pre-1.62
# appends forever → duplicates; ≥1.62 resets/GCs → disappears). So never hoist
# the host out of the fragment; paint the full desired state each tick.
_owns_stream = st.session_state.get('display_queue') is not None
if not _owns_stream and agent.is_running:
st.chat_message("assistant").markdown(T("detached_running"))
@st.fragment(run_every=timedelta(seconds=1 if (_stream_fh is not None or agent.is_running or st.session_state.get('loop_enabled')) else 5))
@st.fragment(run_every=timedelta(seconds=1 if (_owns_stream or agent.is_running or st.session_state.get('loop_enabled')) else 5))
def _tick():
"""Poll every second while active and every five seconds while idle."""
# 1) Own stream: drain done, paint all_outputs
if _stream_fh is not None:
if _owns_stream:
done = _poll_main_task()
if done is not None:
if done:
@@ -466,16 +466,12 @@ def _tick():
_dq = st.session_state.get("display_queue")
steps = (list(((agent.all_outputs or [{}])[-1].get("outputs")) or [])
if _dq is getattr(agent, "_current_queue", None) else [])
# Streamlit ≥1.62: a fragment's outside-container wrapper resets to
# index 0 on every fragment rerun and children not re-emitted are GC'd
# (see runtime/fragment.py:_reset_outside_wrappers). Incremental appends
# don't survive across ticks, so repaint *everything* every tick — frozen
# expanders plus the live tail — into the single host container. Always
# writing (even when steps is empty) also claims the slot on the initial
# run, avoiding "could not reserve a stable position".
live = re.sub(r'\**LLM Running \(Turn \d+\) \.\.\.\**\s*$', '',
(steps[-1] if steps else '') or '').rstrip()
with _stream_fh:
# Idempotent repaint inside the fragment's own subtree: the whole
# bubble (expanders + live tail) is re-emitted from state every tick,
# so a rerun can neither drop nor duplicate elements.
with st.chat_message("assistant"):
for i in range(max(0, len(steps) - 1)):
body = steps[i] or ''
with st.expander(_step_title(body, i), expanded=False): st.markdown(body)