Skip to content

HTMLDependency objects lost during streaming message accumulation #192

@cpsievert

Description

@cpsievert

Problem

When a chat message is delivered via streaming (append_message_stream()), HTMLDependency objects are sent to the client during individual chunks but are not accumulated on the stored message. This means self._messages() contains entries with html_deps=None for streamed messages, even though the deps were present during delivery.

This causes bookmark restore to fail for streamed messages with HTML dependencies — the deps are missing from the stored message, so they can't be serialized into bookmark state.

Reproducible example

Save the following as app.py, create _assets/custom.css with the CSS content below, then run with shiny run app.py:

_assets/custom.css:

.custom-styled-card { border: 3px solid red; padding: 12px; background: #fff0f0; }

app.py:

from pathlib import Path
from typing import Any

from htmltools import HTMLDependency, TagList, tags
from shiny.express import app_opts
from shiny.types import Jsonifiable
from shinychat.express import Chat

app_opts(bookmark_store="server")

custom_dep = HTMLDependency(
    name="custom-styled-card",
    version="1.0.0",
    source={"subdir": str(Path(__file__).parent / "_assets")},
    stylesheet=[{"href": "custom.css"}],
)


class MockClient:
    def __init__(self) -> None:
        self.turns: list[Any] = []

    async def get_state(self) -> Jsonifiable:
        return {"version": 1, "turns": self.turns}

    async def set_state(self, state: Jsonifiable) -> None:
        assert isinstance(state, dict)
        self.turns = state["turns"]


client = MockClient()
chat = Chat(id="chat")
chat.ui()
chat.enable_bookmarking(client, bookmark_on="response")


async def styled_response_stream(user_input: str):
    """Yields a single styled HTML chunk with an HTMLDependency."""
    yield TagList(
        custom_dep,
        tags.div(
            {"class": "custom-styled-card"},
            f"Streamed styled response to: {user_input}",
        ),
    )


@chat.on_user_submit
async def handle_user_input(user_input: str):
    client.turns.append({"role": "user", "content": user_input})
    client.turns.append(
        {"role": "assistant", "content": f"Streamed styled response to: {user_input}"}
    )
    await chat.append_message_stream(styled_response_stream(user_input))

Steps:

  1. Send a message — the response has a red border (CSS from HTMLDependency is loaded)
  2. Open the bookmark URL in a new tab
  3. The message HTML is restored, but the red border is gone — the CSS dependency was not re-sent

Root cause

In _append_message_chunk, the streaming path accumulates text content via self._current_stream_message += str(msg.content), but discards msg.html_deps. At chunk == "end", a new ChatMessage is created from the accumulated string — losing any deps that arrived during the stream.

Python-only

The R package does not have this problem. On bookmark restore, R re-renders the display from scratch by calling contents_shinychat(client) on the restored ellmer client turns, then chat_append() on each result. This re-runs the full rendering pipeline (as.tags()processDeps()), so HTMLDependencies are re-generated from the tag structure rather than saved/restored. Python's restore path instead saves and re-appends serialized message strings ({content: str, role: str}), which don't carry HTMLDependency objects.

Relation to #190 / #191

PR #191 fixed bookmark restore for non-streamed messages by saving deps separately. However, that fix doesn't help streamed messages since their stored html_deps is None — there are no deps to save.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions