Skip to content

fix: resume ChatUI streams after refresh - #9259

Merged
Soulter merged 3 commits into
AstrBotDevs:masterfrom
Soulter:codex/fix-chat-stream-resume
Jul 13, 2026
Merged

Soulter merged 3 commits into
AstrBotDevs:masterfrom
Soulter:codex/fix-chat-stream-resume

Conversation

@Soulter

@Soulter Soulter commented Jul 13, 2026 •

Copy link
Copy Markdown
Member

Summary

  • decouple WebChat generation from the lifetime of the original SSE response
  • expose active run snapshots and a resume stream endpoint for ChatUI refresh recovery
  • resume the oldest active run, retry transient disconnects, and continue with queued follow-up runs
  • surface streaming provider errors in ChatUI and keep the viewport stable when resumed output finishes
  • bound subscriber queues so slow clients reconnect from a fresh snapshot instead of growing memory indefinitely

Root cause

The Chat service consumed runner output inside the request-owned SSE generator. Refreshing ChatUI cancelled that generator, removed the response queue, and left later runner output without a consumer or persisted bot response. The frontend also treated any resumed-stream EOF as completion and selected the newest active run, which could be an empty queued follow-up rather than the run producing tokens.

Compatibility

This is not a breaking change. The existing POST /chat/send request fields and legacy SSE event shapes remain unchanged. Resume metadata and GET /chat/runs/{run_id}/stream are additive.

Validation

  • uv run ruff format --check .
  • uv run ruff check .
  • uv run pytest -q tests/test_chat_route.py tests/test_webchat_queue_lifecycle.py tests/test_astr_agent_run_util.py tests/test_platform_image_format_preservation.py tests/unit/test_webchat_message_parts.py (16 passed)
  • cd dashboard && pnpm typecheck
  • cd dashboard && pnpm exec prettier --check src/composables/useMessages.ts

Fixes #9249.

This takes a different approach from #9256: disconnecting the browser no longer stops the active agent run; ChatUI can reattach and continue receiving its output.

Summary by Sourcery

Decouple WebChat chat run streaming from the original SSE response so runs continue after browser disconnects and can be resumed via a dedicated API, while keeping legacy stream behavior intact.

New Features:

  • Expose resumable chat run state and snapshots in session and thread responses, plus a GET /chat/runs/{run_id}/stream endpoint to reattach ChatUI to active runs.
  • Support resuming the oldest active chat run in a session in the frontend, restoring its partial bot message and continuing the SSE stream with retry logic for transient disconnects.

Bug Fixes:

  • Ensure WebChat runner output is still consumed and bot messages are persisted even if the original SSE client disconnects or refreshes.
  • Handle streaming provider error responses from the agent runner by surfacing them as user-visible message chains, including a safe fallback when the error payload is malformed.
  • Prevent slow chat subscribers from accumulating unbounded buffered output by bounding subscriber queues, closing overrun connections, and forcing reconnect from a fresh snapshot.
  • Unblock and fail pending writer tasks when a WebChat back queue is removed so producers don’t hang on closed streams.

Enhancements:

  • Refactor chat streaming to use an explicit ChatRunState with shared back queues and subscriber fan-out, decoupling run lifecycle from individual SSE subscribers.
  • Include active run metadata in session/thread responses and process run snapshot events in the dashboard so the viewport remains stable when resuming output.
  • Adjust WebChat event routing to use centralized back-queue management and acceptance signals for all payload types, simplifying conversation id handling.

Documentation:

  • Extend the OpenAPI spec to document the new /api/v1/chat/runs/{run_id}/stream endpoint and its SSE-based resume semantics.

Tests:

  • Add comprehensive tests covering chat run resume behavior, subscriber buffer limits, legacy stream compatibility, WebChat queue lifecycle, and streaming provider error forwarding.
  • Update existing WebChat attachment tests to use the new back-queue API while preserving image format and filename expectations.

Comment thread astrbot/dashboard/api/chat.py Fixed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the ability to resume active webchat runs as server-sent event (SSE) streams. It refactors the backend queue management and introduces a ChatRunState dataclass to decouple chat runs from their subscribers, allowing clients to reconnect and resume active streams. On the frontend, it adds support for restoring and resuming active runs. The review feedback suggests several robustness improvements, including defensive checks for streaming error payloads, stringifying message IDs before comparison to avoid type mismatches, and optimizing the retry loop in the frontend stream resume logic when an abort signal is triggered.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread astrbot/core/astr_agent_run_util.py Resolved
@Soulter
Soulter marked this pull request as ready for review July 13, 2026 15:04
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. area:core The bug / feature is about astrbot's core, backend feature:chatui The bug / feature is about astrbot's chatui, webchat labels Jul 13, 2026
@Soulter
Soulter merged commit 6070da9 into AstrBotDevs:master Jul 13, 2026
21 checks passed

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In ChatService._consume_chat_run, the pending_refs variable is never updated but is used in the should_save logic and as a fallback for extract_web_search_refs, which means refs are effectively ignored; consider wiring through any reference data from the stream and reusing the previous flush_pending_bot_message semantics to avoid losing ref information.
  • The logic for constructing active run snapshots (get_active_chat_runs and the snapshot in _subscribe_chat_run) is duplicated and could drift over time; extracting a shared helper for building these snapshot payloads would reduce duplication and make evolution of the resume semantics safer.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In ChatService._consume_chat_run, the pending_refs variable is never updated but is used in the should_save logic and as a fallback for extract_web_search_refs, which means refs are effectively ignored; consider wiring through any reference data from the stream and reusing the previous flush_pending_bot_message semantics to avoid losing ref information.
- The logic for constructing active run snapshots (get_active_chat_runs and the snapshot in _subscribe_chat_run) is duplicated and could drift over time; extracting a shared helper for building these snapshot payloads would reduce duplication and make evolution of the resume semantics safer.

## Individual Comments

### Comment 1
<location path="astrbot/dashboard/services/chat_service.py" line_range="887-896" />
<code_context>
+    async def _consume_chat_run(self, run: ChatRunState) -> None:
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Pending refs are never updated from streamed data and only overwritten by extraction errors.

In `_consume_chat_run`, `pending_refs` is created and passed to `flush_pending_bot_message` but is only ever set when `extract_web_search_refs` raises, so it stays `{}` and the fallback has no effect. If you intended to preserve refs from other sources (e.g., tool outputs or intermediate messages), you should update `pending_refs` whenever new refs are found; otherwise, consider removing it to avoid misleading, unused state.

Suggested implementation:

```python
        pending_accumulator = BotMessageAccumulator()
        display_accumulator = BotMessageAccumulator()
        pending_agent_stats = {}

```

To fully remove the misleading `pending_refs` state, you should also:
1. Search for all uses of `pending_refs` in `astrbot/dashboard/services/chat_service.py` (e.g., parameters to `flush_pending_bot_message` or tracking structures inside `_consume_chat_run`) and delete the variable and parameter.
2. Update the signature of `flush_pending_bot_message` (if it accepts `pending_refs`) to drop that parameter, and remove any logic that assumes `pending_refs` is passed in.
3. If there is error-handling code that sets `pending_refs` based on `extract_web_search_refs` failures, remove that logic or refactor it so that errors are handled without relying on `pending_refs`.
These cleanups will ensure there is no unused or misleading state related to pending refs and will keep the implementation consistent with the current behavior (no ref preservation across streamed messages).
</issue_to_address>

### Comment 2
<location path="astrbot/core/platform/sources/webchat/webchat_queue_mgr.py" line_range="48" />
<code_context>
             self._conversation_back_requests[conversation_id].add(request_id)
         return self.back_queues[request_id]

+    async def put_back_queue(self, request_id: str, data: object) -> bool:
+        """Write a response while the request queue remains active.
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the close-aware put logic into a helper or small abstraction so `put_back_queue` expresses only the high-level intent instead of detailed task coordination.

The `put_back_queue` coordination logic is doing the right thing semantically, but the per‑call two‑task race and manual cancellation makes the queue manager harder to read and maintain. You can keep the current behavior while encapsulating the complexity in a small helper/wrapper, so the queue manager only expresses the high‑level intent.

### 1. Extract the close‑aware put behavior into a helper

Move the low‑level concurrency into a private method. This keeps `put_back_queue` simple and avoids repeating the task/cancel pattern if you ever need it elsewhere:

```python
class WebChatQueueMgr:
    ...

    async def _put_while_open(
        self,
        queue: asyncio.Queue,
        close_event: asyncio.Event,
        data: object,
    ) -> bool:
        try:
            queue.put_nowait(data)
            return True
        except asyncio.QueueFull:
            pass

        put_task = asyncio.create_task(queue.put(data))
        close_task = asyncio.create_task(close_event.wait())
        try:
            done, _ = await asyncio.wait(
                {put_task, close_task},
                return_when=asyncio.FIRST_COMPLETED,
            )
            if close_task in done:
                return False
            await put_task
            return True
        finally:
            for task in (put_task, close_task):
                if not task.done():
                    task.cancel()
            await asyncio.gather(put_task, close_task, return_exceptions=True)

    async def put_back_queue(self, request_id: str, data: object) -> bool:
        queue = self.back_queues.get(request_id)
        close_event = self._back_queue_close_events.get(request_id)
        if queue is None or close_event is None or close_event.is_set():
            return False

        return await self._put_while_open(queue, close_event, data)
```

This preserves all current semantics (including unblocking immediately on close) but makes `put_back_queue` much easier to understand: it now reads as “put while open” instead of exposing task management details.

### 2. Optionally encapsulate queues + close events into a small abstraction

If you find yourself needing similar behavior for front queues later, consider a focused wrapper. The manager then only stores and uses that abstraction, reducing asymmetry between front/back queues:

```python
class ClosableQueue:
    def __init__(self, maxsize: int):
        self.queue = asyncio.Queue(maxsize=maxsize)
        self.close_event = asyncio.Event()

    async def put(self, item: object) -> bool:
        if self.close_event.is_set():
            return False
        # reuse _put_while_open logic here or inline it
        ...

    def close(self) -> None:
        self.close_event.set()
```

Then the manager would hold `dict[str, ClosableQueue]` and `put_back_queue` becomes a direct call to `ClosableQueue.put`, avoiding per‑call manual coordination code in the manager itself.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +887 to +896
async def _consume_chat_run(self, run: ChatRunState) -> None:
"""Drain runner output, persist it, and fan it out to subscribers.

Args:
run: Chat run owning the producer queue and durable state.
"""
pending_accumulator = BotMessageAccumulator()
display_accumulator = BotMessageAccumulator()
pending_agent_stats = {}
pending_refs = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Pending refs are never updated from streamed data and only overwritten by extraction errors.

In _consume_chat_run, pending_refs is created and passed to flush_pending_bot_message but is only ever set when extract_web_search_refs raises, so it stays {} and the fallback has no effect. If you intended to preserve refs from other sources (e.g., tool outputs or intermediate messages), you should update pending_refs whenever new refs are found; otherwise, consider removing it to avoid misleading, unused state.

Suggested implementation:

        pending_accumulator = BotMessageAccumulator()
        display_accumulator = BotMessageAccumulator()
        pending_agent_stats = {}

To fully remove the misleading pending_refs state, you should also:

  1. Search for all uses of pending_refs in astrbot/dashboard/services/chat_service.py (e.g., parameters to flush_pending_bot_message or tracking structures inside _consume_chat_run) and delete the variable and parameter.
  2. Update the signature of flush_pending_bot_message (if it accepts pending_refs) to drop that parameter, and remove any logic that assumes pending_refs is passed in.
  3. If there is error-handling code that sets pending_refs based on extract_web_search_refs failures, remove that logic or refactor it so that errors are handled without relying on pending_refs.
    These cleanups will ensure there is no unused or misleading state related to pending refs and will keep the implementation consistent with the current behavior (no ref preservation across streamed messages).

self._conversation_back_requests[conversation_id].add(request_id)
return self.back_queues[request_id]

async def put_back_queue(self, request_id: str, data: object) -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the close-aware put logic into a helper or small abstraction so put_back_queue expresses only the high-level intent instead of detailed task coordination.

The put_back_queue coordination logic is doing the right thing semantically, but the per‑call two‑task race and manual cancellation makes the queue manager harder to read and maintain. You can keep the current behavior while encapsulating the complexity in a small helper/wrapper, so the queue manager only expresses the high‑level intent.

1. Extract the close‑aware put behavior into a helper

Move the low‑level concurrency into a private method. This keeps put_back_queue simple and avoids repeating the task/cancel pattern if you ever need it elsewhere:

class WebChatQueueMgr:
    ...

    async def _put_while_open(
        self,
        queue: asyncio.Queue,
        close_event: asyncio.Event,
        data: object,
    ) -> bool:
        try:
            queue.put_nowait(data)
            return True
        except asyncio.QueueFull:
            pass

        put_task = asyncio.create_task(queue.put(data))
        close_task = asyncio.create_task(close_event.wait())
        try:
            done, _ = await asyncio.wait(
                {put_task, close_task},
                return_when=asyncio.FIRST_COMPLETED,
            )
            if close_task in done:
                return False
            await put_task
            return True
        finally:
            for task in (put_task, close_task):
                if not task.done():
                    task.cancel()
            await asyncio.gather(put_task, close_task, return_exceptions=True)

    async def put_back_queue(self, request_id: str, data: object) -> bool:
        queue = self.back_queues.get(request_id)
        close_event = self._back_queue_close_events.get(request_id)
        if queue is None or close_event is None or close_event.is_set():
            return False

        return await self._put_while_open(queue, close_event, data)

This preserves all current semantics (including unblocking immediately on close) but makes put_back_queue much easier to understand: it now reads as “put while open” instead of exposing task management details.

2. Optionally encapsulate queues + close events into a small abstraction

If you find yourself needing similar behavior for front queues later, consider a focused wrapper. The manager then only stores and uses that abstraction, reducing asymmetry between front/back queues:

class ClosableQueue:
    def __init__(self, maxsize: int):
        self.queue = asyncio.Queue(maxsize=maxsize)
        self.close_event = asyncio.Event()

    async def put(self, item: object) -> bool:
        if self.close_event.is_set():
            return False
        # reuse _put_while_open logic here or inline it
        ...

    def close(self) -> None:
        self.close_event.set()

Then the manager would hold dict[str, ClosableQueue] and put_back_queue becomes a direct call to ClosableQueue.put, avoiding per‑call manual coordination code in the manager itself.

@Soulter
Soulter deleted the codex/fix-chat-stream-resume branch July 13, 2026 15:07
BegoniaHe pushed a commit to Xero-Team/AstrBot that referenced this pull request Jul 14, 2026
* fix: resume ChatUI streams after refresh

* fix: hide chat run service errors

* fix: harden resumed chat streams
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core The bug / feature is about astrbot's core, backend feature:chatui The bug / feature is about astrbot's chatui, webchat size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] ChatUI 模型思考时刷新页面,回复丢失

2 participants