From 1cacd4da2976114e708a71ce4ebab13b6498e9be Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 13:58:31 -0400 Subject: [PATCH 01/13] feat(py): Add `chat_mod_ui()` and `chat_mod_server()` module functions Batteries-included module wrappers that wire a chatlas client to the shinychat UI, mirroring the R package's `chat_mod_ui()`/`chat_mod_server()`. Features: - Automatic streaming (user submit -> stream_async -> append_message_stream) - Cancel support via ExtendedTask.cancel() - Reactive status(), last_input(), last_turn() accessors - set_client() with deferred swap during streaming - clear() with client_history management - Greeting system (static, callable, callable with client clone) - Bookmarking integration with re-registration on client swap Closes #144 --- pkg-py/src/shinychat/__init__.py | 4 + pkg-py/src/shinychat/_chat_module.py | 491 +++++++++++++++++++++++++++ 2 files changed, 495 insertions(+) create mode 100644 pkg-py/src/shinychat/_chat_module.py diff --git a/pkg-py/src/shinychat/__init__.py b/pkg-py/src/shinychat/__init__.py index 34d9e89a..7afa80f1 100644 --- a/pkg-py/src/shinychat/__init__.py +++ b/pkg-py/src/shinychat/__init__.py @@ -1,10 +1,14 @@ from ._chat import Chat, chat_greeting, chat_ui +from ._chat_module import ChatServerState, chat_mod_server, chat_mod_ui from ._chat_normalize import message_content, message_content_chunk from ._markdown_stream import MarkdownStream, output_markdown_stream __all__ = [ "Chat", + "ChatServerState", "chat_greeting", + "chat_mod_server", + "chat_mod_ui", "chat_ui", "MarkdownStream", "output_markdown_stream", diff --git a/pkg-py/src/shinychat/_chat_module.py b/pkg-py/src/shinychat/_chat_module.py new file mode 100644 index 00000000..5461f551 --- /dev/null +++ b/pkg-py/src/shinychat/_chat_module.py @@ -0,0 +1,491 @@ +from __future__ import annotations + +import copy +import inspect +from typing import ( + TYPE_CHECKING, + Any, + AsyncIterable, + Callable, + Iterable, + Literal, + Optional, + Union, +) + +from htmltools import HTML, Tag, TagAttrValue, TagChild, TagList + +from ._chat import Chat, chat_ui +from ._chat_types import ( + ChatGreeting, + ChatMessage, + ChatMessageDict, +) + +if TYPE_CHECKING: + import chatlas + from shiny.ui.css import CssUnit + +__all__ = ( + "chat_mod_ui", + "chat_mod_server", + "ChatServerState", +) + + +def chat_mod_ui( + id: str, + *, + messages: Optional[ + Iterable[Union[str, TagChild, ChatMessageDict, ChatMessage, Any]] + ] = None, + placeholder: str = "Enter a message...", + width: CssUnit = "min(680px, 100%)", + height: CssUnit = "auto", + fill: bool = True, + icon_assistant: Optional[Union[HTML, Tag, TagList]] = None, + footer: Optional[TagChild] = None, + **kwargs: TagAttrValue, +) -> Tag: + """ + UI for a batteries-included chat module. + + Use with :func:`~shinychat.chat_mod_server` to create a complete chat interface + that automatically wires a chatlas client to the chat UI. + + Parameters + ---------- + id + The module ID. Must match the ``id`` passed to :func:`~shinychat.chat_mod_server`. + messages + Initial messages to display in the chat. + placeholder + Placeholder text for the chat input. + width + The width of the chat container. + height + The height of the chat container. + fill + Whether the chat should vertically take available space inside a fillable + container. + icon_assistant + The icon to use for assistant chat messages. + footer + Optional HTML content to display below the chat input. + kwargs + Additional attributes for the chat container element. + """ + from shiny.module import resolve_id + + resolved = resolve_id(id) + return chat_ui( + f"{resolved}-chat", + messages=messages, + enable_cancel=True, + placeholder=placeholder, + width=width, + height=height, + fill=fill, + icon_assistant=icon_assistant, + footer=footer, + **kwargs, # pyright: ignore[reportArgumentType] + ) + + +class ChatServerState: + """ + Return value from :func:`~shinychat.chat_mod_server`. + + Provides reactive access to the chat module state and methods to interact with + the chat programmatically. + """ + + def __init__( + self, + *, + _chat: Chat, + _last_input_rv: Any, + _last_turn_rv: Any, + _status_rv: Any, + _client_ref: list[Any], + _set_client_fn: Callable[..., None], + _clear_fn: Callable[..., Any], + _set_greeting_fn: Callable[..., Any], + ): + self._chat = _chat + self._last_input_rv = _last_input_rv + self._last_turn_rv = _last_turn_rv + self._status_rv = _status_rv + self._client_ref = _client_ref + self._set_client_fn = _set_client_fn + self._clear_fn = _clear_fn + self._set_greeting_fn = _set_greeting_fn + + def last_input(self) -> Optional[str]: + """ + Reactively read the last user input message. + """ + return self._last_input_rv() + + def last_turn(self) -> Any: + """ + Reactively read the last assistant turn. + + Returns a ``chatlas.Turn`` object or ``None`` if no turn has completed. + """ + return self._last_turn_rv() + + def status(self) -> Literal["streaming", "idle"]: + """ + Reactively read the streaming status. + + Returns ``"streaming"`` while a response is being generated, or ``"idle"`` + otherwise. + """ + return self._status_rv() + + @property + def client(self) -> chatlas.Chat[Any, Any]: + """ + The current chatlas client. + """ + return self._client_ref[0] + + def update_user_input( + self, + value: Optional[str] = None, + *, + placeholder: Optional[str] = None, + submit: bool = False, + focus: bool = False, + ) -> None: + """ + Update the chat user input. + + Parameters + ---------- + value + The value to set in the user input box. + placeholder + New placeholder text. + submit + Whether to automatically submit the value. + focus + Whether to move focus to the input element. + """ + self._chat.update_user_input( + value=value, placeholder=placeholder, submit=submit, focus=focus + ) + + async def append( + self, + response: Any, + *, + role: str = "assistant", + icon: Optional[Union[HTML, Tag, TagList]] = None, + ) -> None: + """ + Append a message or stream to the chat. + + Parameters + ---------- + response + A message string, HTML, or async iterable of chunks. + role + The role of the message (``"assistant"`` or ``"user"``). + icon + Optional icon to display next to the message. + """ + if isinstance(response, AsyncIterable): + stream_icon = icon if isinstance(icon, (HTML, Tag)) else None + await self._chat.append_message_stream(response, icon=stream_icon) + else: + msg: ChatMessageDict = { + "content": str(response) if not isinstance(response, (HTML, Tag, TagList)) else response, # type: ignore[typeddict-item] + "role": role, # type: ignore[typeddict-item] + } + await self._chat.append_message(msg, icon=icon) + + async def clear( + self, + messages: Optional[list[Any]] = None, + greeting: bool = False, + client_history: Literal["clear", "set", "append", "keep"] = "clear", + ) -> None: + """ + Clear the chat messages. + + Parameters + ---------- + messages + Optional list of messages to display after clearing. Each item should be a + dict with ``role`` and ``content`` keys, or a string (treated as an assistant + message). + greeting + If ``True``, also clear the greeting, which causes the + ``{id}_greeting_requested`` input to fire again. + client_history + How to handle the chatlas client's turn history: + + - ``"clear"``: wipe all turns (default). + - ``"set"``: replace turns with the ``messages`` provided. + - ``"append"``: append ``messages`` to the existing turns. + - ``"keep"``: leave the client turns unchanged. + """ + await self._clear_fn( + messages=messages, greeting=greeting, client_history=client_history + ) + + async def set_greeting( + self, + greeting: Union[str, HTML, Tag, TagList, ChatGreeting, None], + ) -> None: + """ + Set or clear the chat greeting. + + Parameters + ---------- + greeting + The greeting content. See :meth:`~shinychat.Chat.set_greeting` for details. + """ + await self._set_greeting_fn(greeting) + + def set_client(self, new_client: chatlas.Chat[Any, Any], *, sync: bool = True) -> None: + """ + Replace the chatlas client. + + If the chat is currently streaming, the swap is deferred until the stream + completes. + + Parameters + ---------- + new_client + The new chatlas client to use. + sync + If ``True`` (the default), the new client's turn history, system prompt, + and tools are set to match the current client before swapping. + """ + self._set_client_fn(new_client, sync=sync) + + +def chat_mod_server( + id: str, + client: chatlas.Chat[Any, Any], + *, + greeting: Optional[ + Union[str, HTML, TagList, ChatGreeting, Callable[..., Any]] + ] = None, + bookmark_on_input: bool = True, + bookmark_on_response: bool = True, +) -> ChatServerState: + """ + Server for a batteries-included chat module. + + Automatically wires a chatlas client to the chat UI created by + :func:`~shinychat.chat_mod_ui`, handling streaming, cancellation, bookmarking, + and optional greetings. + + Parameters + ---------- + id + The module ID. Must match the ``id`` passed to :func:`~shinychat.chat_mod_ui`. + client + A chatlas client (e.g., ``chatlas.ChatOpenAI()``) used to generate responses. + greeting + An optional greeting shown before any conversation messages. Accepts: + + - A static string, :class:`~htmltools.HTML`, :class:`~htmltools.TagList`, or + :func:`~shinychat.chat_greeting`. + - A callable that returns a greeting. The callable may optionally accept a + ``client`` argument; if so, a fresh clone of ``client`` (with empty turn + history) is passed to it. The callable is re-invoked each time + ``{id}_greeting_requested`` fires (on first view and after ``clear()``). + bookmark_on_input + Whether to trigger a bookmark when the user submits a message. + bookmark_on_response + Whether to trigger a bookmark when the assistant finishes responding. + + Returns + ------- + : + A :class:`~shinychat.ChatServerState` instance with reactive accessors and + methods for interacting with the chat programmatically. + """ + try: + import chatlas as _chatlas # noqa: F401 + except ImportError as e: + raise ImportError( + "chatlas is required for chat_mod_server(). " + "Install it with: pip install chatlas" + ) from e + + from shiny import module, reactive + + @module.server + def _server( + input: Any, + output: Any, + session: Any, + client: Any, + greeting: Any, + bookmark_on_input: bool, + bookmark_on_response: bool, + ) -> ChatServerState: + chat = Chat("chat") + client_ref: list[Any] = [client] + + _last_input: reactive.Value[Optional[str]] = reactive.Value(None) + _last_turn: reactive.Value[Any] = reactive.Value(None) + _status: reactive.Value[Literal["streaming", "idle"]] = reactive.Value("idle") + _pending_swap: reactive.Value[Optional[dict[str, Any]]] = reactive.Value(None) + + def _swap_client(new_client: Any, sync: bool) -> None: + if sync: + new_client.set_turns(client_ref[0].get_turns()) + new_client.system_prompt = client_ref[0].system_prompt + new_client.set_tools(client_ref[0].get_tools()) + client_ref[0] = new_client + + with reactive.isolate(): + _re_enable_bookmarking() + + def _re_enable_bookmarking() -> None: + try: + chat.enable_bookmarking( + client_ref[0], + bookmark_on="response" if bookmark_on_response else None, + ) + except Exception: + pass + + def _set_client(new_client: Any, sync: bool = True) -> None: + with reactive.isolate(): + streaming = _status() == "streaming" + + if streaming: + _pending_swap.set({"client": new_client, "sync": sync}) + return + + _swap_client(new_client, sync) + + @chat.on_user_submit + async def _handle_submit(user_input: str) -> None: + _last_input.set(user_input) + _status.set("streaming") + response = client_ref[0].stream_async(user_input, content="all") + await chat.append_message_stream(response) + + @reactive.effect + @reactive.event(lambda: input["chat_cancel"]) + def _on_cancel() -> None: + stream = chat.latest_message_stream + stream.cancel() + + @reactive.effect + def _on_stream_complete() -> None: + stream = chat.latest_message_stream + stream_status = stream.status() + + if stream_status == "success": + with reactive.isolate(): + _last_turn.set(client_ref[0].get_last_turn()) + _status.set("idle") + elif stream_status in ("error", "cancelled"): + _status.set("idle") + + with reactive.isolate(): + swap = _pending_swap() + current_status = _status() + + if swap is not None and current_status != "streaming": + _pending_swap.set(None) + _swap_client(swap["client"], swap["sync"]) + + async def _clear( + messages: Optional[list[Any]] = None, + greeting: bool = False, + client_history: Literal["clear", "set", "append", "keep"] = "clear", + ) -> None: + await chat.clear_messages(greeting=greeting) + + normalized: list[dict[str, str]] = [] + if messages is not None: + for msg in messages: + if isinstance(msg, str): + normalized.append({"role": "assistant", "content": msg}) + elif isinstance(msg, dict): + normalized.append({ + "role": msg.get("role", "assistant"), + "content": str(msg.get("content", "")), + }) + else: + normalized.append({"role": "assistant", "content": str(msg)}) + + for msg in normalized: + await chat.append_message({ + "content": msg["content"], + "role": msg["role"], + }) + + if client_history == "clear": + client_ref[0].set_turns([]) + elif client_history == "set": + client_ref[0].set_turns([]) + elif client_history == "append": + pass + # "keep" does nothing + + _last_turn.set(None) + _last_input.set(None) + + if callable(greeting) and not isinstance(greeting, (str, ChatGreeting)): + greeting_sig = inspect.signature(greeting) + greeting_params = list(greeting_sig.parameters.keys()) + + @reactive.effect + @reactive.event(lambda: input["chat_greeting_requested"]) + async def _on_greeting_requested() -> None: + args: dict[str, Any] = {} + if "client" in greeting_params: + greeter = copy.deepcopy(client_ref[0]) + greeter.set_turns([]) + args["client"] = greeter + + result: Any = greeting(**args) + if inspect.isawaitable(result): + result = await result + await chat.set_greeting(result) + + elif greeting is not None: + + @reactive.effect + async def _set_initial_greeting() -> None: + await chat.set_greeting(greeting) + _set_initial_greeting.destroy() # type: ignore[attr-defined] + + try: + chat.enable_bookmarking( + client_ref[0], + bookmark_on="response" if bookmark_on_response else None, + ) + except Exception: + pass + + return ChatServerState( + _chat=chat, + _last_input_rv=_last_input, + _last_turn_rv=_last_turn, + _status_rv=_status, + _client_ref=client_ref, + _set_client_fn=_set_client, + _clear_fn=_clear, + _set_greeting_fn=chat.set_greeting, + ) + + return _server( + id, + client=client, + greeting=greeting, + bookmark_on_input=bookmark_on_input, + bookmark_on_response=bookmark_on_response, + ) From 42ede7ea7e2739417ad122271c62e345b10f62fb Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 14:06:18 -0400 Subject: [PATCH 02/13] fix(py): Use ResolvedId for namespaced chat ID in chat_mod_ui() Prevents Shiny's ID validation from rejecting the hyphenated "{module_id}-chat" string. --- pkg-py/src/shinychat/_chat_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg-py/src/shinychat/_chat_module.py b/pkg-py/src/shinychat/_chat_module.py index 5461f551..556d3d34 100644 --- a/pkg-py/src/shinychat/_chat_module.py +++ b/pkg-py/src/shinychat/_chat_module.py @@ -75,11 +75,11 @@ def chat_mod_ui( kwargs Additional attributes for the chat container element. """ - from shiny.module import resolve_id + from shiny.module import ResolvedId, resolve_id resolved = resolve_id(id) return chat_ui( - f"{resolved}-chat", + ResolvedId(f"{resolved}-chat"), messages=messages, enable_cancel=True, placeholder=placeholder, From be4ac60451aa7b622fde31bcc9d4184dc0192eb0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 14:06:23 -0400 Subject: [PATCH 03/13] test(py): Add unit and Playwright tests for chat_mod_ui/chat_mod_server 31 pytest unit tests covering chat_mod_ui() rendering, ChatServerState interface structure, and imports. 5 Playwright integration tests covering UI rendering, cancel button, message submission/response, status tracking, and multi-message flow. --- .../tests/playwright/chat/chat_module/app.py | 65 +++++ .../chat/chat_module/test_chat_module.py | 65 +++++ pkg-py/tests/pytest/test_chat_module.py | 267 ++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 pkg-py/tests/playwright/chat/chat_module/app.py create mode 100644 pkg-py/tests/playwright/chat/chat_module/test_chat_module.py create mode 100644 pkg-py/tests/pytest/test_chat_module.py diff --git a/pkg-py/tests/playwright/chat/chat_module/app.py b/pkg-py/tests/playwright/chat/chat_module/app.py new file mode 100644 index 00000000..ba8a034a --- /dev/null +++ b/pkg-py/tests/playwright/chat/chat_module/app.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any, Optional + +from shiny import App, Inputs, Outputs, Session, reactive, render, ui + +from shinychat import chat_mod_server, chat_mod_ui + + +class MockChat: + def __init__(self): + self._turns: list[Any] = [] + self._system_prompt: str = "" + self._tools: list[Any] = [] + + def stream_async(self, user_input: str, content: str = "all"): + async def _stream(): + for word in f"Echo: {user_input}".split(): + yield word + " " + + return _stream() + + def get_turns(self) -> list[Any]: + return self._turns + + def set_turns(self, turns: list[Any]) -> None: + self._turns = turns + + @property + def system_prompt(self) -> str: + return self._system_prompt + + @system_prompt.setter + def system_prompt(self, val: str) -> None: + self._system_prompt = val + + def get_tools(self) -> list[Any]: + return self._tools + + def set_tools(self, tools: list[Any]) -> None: + self._tools = tools + + def get_last_turn(self) -> Optional[Any]: + return None + + +app_ui = ui.page_fillable( + ui.panel_title("Chat Module Test"), + chat_mod_ui("chatmod"), + ui.output_text("status_out"), + fillable_mobile=True, +) + + +def server(input: Inputs, output: Outputs, session: Session): + client = MockChat() + state = chat_mod_server("chatmod", client=client) + + @output + @render.text + def status_out(): + return state.status() + + +app = App(app_ui, server) diff --git a/pkg-py/tests/playwright/chat/chat_module/test_chat_module.py b/pkg-py/tests/playwright/chat/chat_module/test_chat_module.py new file mode 100644 index 00000000..473112d0 --- /dev/null +++ b/pkg-py/tests/playwright/chat/chat_module/test_chat_module.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from playwright.sync_api import Page, expect +from shiny.run import ShinyAppProc +from shinychat.playwright import ChatController + + +def test_chat_module_renders(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chatmod-chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + +def test_chat_module_cancel_button_present( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chatmod-chat") + expect(chat.loc).to_be_visible(timeout=30_000) + cancel_btn = chat.loc.locator(".shiny-chat-btn-cancel") + expect(cancel_btn).to_be_hidden() + + +def test_chat_module_submit_and_receive_response( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chatmod-chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + chat.set_user_input("hello") + chat.send_user_input() + chat.expect_latest_message("Echo: hello ", timeout=30_000) + + +def test_chat_module_status_starts_idle( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chatmod-chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + status = page.locator("#status_out") + expect(status).to_have_text("idle", timeout=10_000) + + +def test_chat_module_multiple_messages( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chatmod-chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + chat.set_user_input("first") + chat.send_user_input() + chat.expect_latest_message("Echo: first ", timeout=30_000) + + chat.set_user_input("second") + chat.send_user_input() + chat.expect_latest_message("Echo: second ", timeout=30_000) diff --git a/pkg-py/tests/pytest/test_chat_module.py b/pkg-py/tests/pytest/test_chat_module.py new file mode 100644 index 00000000..b233ce15 --- /dev/null +++ b/pkg-py/tests/pytest/test_chat_module.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import inspect +from typing import Any, cast + +import pytest +from htmltools import HTML, Tag, TagList, tags +from shiny import Session +from shiny.module import ResolvedId +from shiny.session import session_context + +from shinychat import ChatServerState, chat_mod_ui, chat_mod_server + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _MockSession: + ns: ResolvedId = ResolvedId("") + app: object = None + id: str = "mock-session" + + def on_ended(self, callback: object) -> None: + pass + + def on_destroy(self, callback: object) -> None: + pass + + def _increment_busy_count(self) -> None: + pass + + +test_session = cast(Session, _MockSession()) + + +# --------------------------------------------------------------------------- +# chat_mod_ui() tests +# --------------------------------------------------------------------------- + + +def test_chat_mod_ui_returns_tag(): + with session_context(test_session): + result = chat_mod_ui("mymod") + assert isinstance(result, Tag) + + +def test_chat_mod_ui_namespaced_id(): + with session_context(test_session): + result = chat_mod_ui("mymod") + html = result.get_html_string() + assert 'id="mymod-chat"' in html + + +def test_chat_mod_ui_different_ids_are_distinct(): + with session_context(test_session): + result_a = chat_mod_ui("alpha") + result_b = chat_mod_ui("beta") + html_a = result_a.get_html_string() + html_b = result_b.get_html_string() + assert 'id="alpha-chat"' in html_a + assert 'id="beta-chat"' in html_b + assert 'id="beta-chat"' not in html_a + assert 'id="alpha-chat"' not in html_b + + +def test_chat_mod_ui_enable_cancel_attribute(): + with session_context(test_session): + result = chat_mod_ui("mymod") + html = result.get_html_string() + assert "enable-cancel" in html + + +def test_chat_mod_ui_default_placeholder(): + with session_context(test_session): + result = chat_mod_ui("mymod") + html = result.get_html_string() + assert "Enter a message..." in html + + +def test_chat_mod_ui_custom_placeholder(): + with session_context(test_session): + result = chat_mod_ui("mymod", placeholder="Ask me anything") + html = result.get_html_string() + assert "Ask me anything" in html + + +def test_chat_mod_ui_custom_width(): + with session_context(test_session): + result = chat_mod_ui("mymod", width="400px") + html = result.get_html_string() + assert "400px" in html + + +def test_chat_mod_ui_custom_height(): + with session_context(test_session): + result = chat_mod_ui("mymod", height="600px") + html = result.get_html_string() + assert "600px" in html + + +def test_chat_mod_ui_with_footer(): + footer = tags.div("footer content", id="my-footer") + with session_context(test_session): + result = chat_mod_ui("mymod", footer=footer) + html = result.get_html_string() + assert "footer content" in html + assert "my-footer" in html + + +def test_chat_mod_ui_with_tag_icon_assistant(): + icon = tags.span("bot", id="robot-icon") + with session_context(test_session): + result = chat_mod_ui("mymod", icon_assistant=icon) + html = result.get_html_string() + assert "robot-icon" in html + + +def test_chat_mod_ui_with_html_icon_assistant(): + icon = HTML("bot") + with session_context(test_session): + result = chat_mod_ui("mymod", icon_assistant=icon) + html = result.get_html_string() + assert "bot" in html + + +def test_chat_mod_ui_with_messages(): + messages = [{"role": "assistant", "content": "Hello there!"}] + with session_context(test_session): + result = chat_mod_ui("mymod", messages=messages) + html = result.get_html_string() + assert "Hello there!" in html + + +def test_chat_mod_ui_fill_true_by_default(): + with session_context(test_session): + result = chat_mod_ui("mymod") + html = result.get_html_string() + assert "fill" in html.lower() + + +def test_chat_mod_ui_fill_false(): + with session_context(test_session): + result = chat_mod_ui("mymod", fill=False) + html = result.get_html_string() + assert "html-fill-container" not in html + + +# --------------------------------------------------------------------------- +# ChatServerState structural tests +# --------------------------------------------------------------------------- + + +def test_chat_server_state_has_last_input_method(): + assert callable(getattr(ChatServerState, "last_input", None)) + + +def test_chat_server_state_has_last_turn_method(): + assert callable(getattr(ChatServerState, "last_turn", None)) + + +def test_chat_server_state_has_status_method(): + assert callable(getattr(ChatServerState, "status", None)) + + +def test_chat_server_state_client_is_property(): + members = dict( + inspect.getmembers(ChatServerState, lambda v: isinstance(v, property)) + ) + assert "client" in members + + +def test_chat_server_state_has_update_user_input_method(): + assert callable(getattr(ChatServerState, "update_user_input", None)) + + +def test_chat_server_state_has_append_method(): + method = getattr(ChatServerState, "append", None) + assert callable(method) + assert inspect.iscoroutinefunction(method) + + +def test_chat_server_state_has_clear_method(): + method = getattr(ChatServerState, "clear", None) + assert callable(method) + assert inspect.iscoroutinefunction(method) + + +def test_chat_server_state_has_set_greeting_method(): + method = getattr(ChatServerState, "set_greeting", None) + assert callable(method) + assert inspect.iscoroutinefunction(method) + + +def test_chat_server_state_has_set_client_method(): + assert callable(getattr(ChatServerState, "set_client", None)) + + +def test_chat_server_state_update_user_input_signature(): + sig = inspect.signature(ChatServerState.update_user_input) + params = list(sig.parameters.keys()) + assert "value" in params + assert "placeholder" in params + assert "submit" in params + assert "focus" in params + + +def test_chat_server_state_clear_signature(): + sig = inspect.signature(ChatServerState.clear) + params = list(sig.parameters.keys()) + assert "messages" in params + assert "greeting" in params + assert "client_history" in params + + +def test_chat_server_state_set_client_signature(): + sig = inspect.signature(ChatServerState.set_client) + params = list(sig.parameters.keys()) + assert "new_client" in params + assert "sync" in params + + +def test_chat_server_state_append_signature(): + sig = inspect.signature(ChatServerState.append) + params = list(sig.parameters.keys()) + assert "response" in params + assert "role" in params + assert "icon" in params + + +# --------------------------------------------------------------------------- +# Import tests +# --------------------------------------------------------------------------- + + +def test_import_chat_mod_ui(): + from shinychat import chat_mod_ui as _fn + + assert callable(_fn) + + +def test_import_chat_mod_server(): + from shinychat import chat_mod_server as _fn + + assert callable(_fn) + + +def test_import_chat_server_state(): + from shinychat import ChatServerState as _cls + + assert isinstance(_cls, type) + + +def test_chatlas_importable(): + """chatlas must be importable since chat_mod_server requires it.""" + try: + import chatlas # noqa: F401 + + chatlas_available = True + except ImportError: + chatlas_available = False + + if not chatlas_available: + pytest.skip("chatlas is not installed") + + assert chatlas_available From d4b19b70dab4472fa4c73e5b00efd1169cd40992 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 14:06:27 -0400 Subject: [PATCH 04/13] docs(py): Add chat_mod_ui, chat_mod_server, ChatServerState to API reference Adds a new "Chat Module" section to the quartodoc config. --- pkg-py/docs/_quarto.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml index c6f10ad0..6b47ea4c 100644 --- a/pkg-py/docs/_quarto.yml +++ b/pkg-py/docs/_quarto.yml @@ -71,6 +71,18 @@ quartodoc: - Chat - chat_ui - chat_greeting + - title: Chat Module + options: + signature_name: relative + include_imports: false + include_inherited: false + include_attributes: true + include_classes: true + include_functions: true + contents: + - chat_mod_ui + - chat_mod_server + - ChatServerState - title: Shiny Express options: signature_name: relative From 0a4eb699f0ab815b5ceeb163d32db24450065307 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 14:27:24 -0400 Subject: [PATCH 05/13] fix(py): Address review findings in chat_mod_server - Remove `bookmark_on_input` parameter (dead code; underlying Chat.enable_bookmarking() only supports response-triggered bookmarks) - Implement `clear(client_history="set"/"append")` properly using chatlas UserTurn/AssistantTurn instead of no-op stubs - Fix cancel button test to verify `enable-cancel` attribute on the container rather than asserting DOM presence of a conditionally- rendered element --- pkg-py/src/shinychat/_chat_module.py | 24 +++++++++++++------ .../chat/chat_module/test_chat_module.py | 5 ++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/pkg-py/src/shinychat/_chat_module.py b/pkg-py/src/shinychat/_chat_module.py index 556d3d34..072ccd47 100644 --- a/pkg-py/src/shinychat/_chat_module.py +++ b/pkg-py/src/shinychat/_chat_module.py @@ -33,6 +33,20 @@ ) +def _messages_to_turns(messages: list[dict[str, str]]) -> list[Any]: + from chatlas import AssistantTurn, UserTurn + + turns: list[Any] = [] + for msg in messages: + role = msg.get("role", "assistant") + content = msg.get("content", "") + if role == "user": + turns.append(UserTurn(content)) + else: + turns.append(AssistantTurn(content)) + return turns + + def chat_mod_ui( id: str, *, @@ -275,7 +289,6 @@ def chat_mod_server( greeting: Optional[ Union[str, HTML, TagList, ChatGreeting, Callable[..., Any]] ] = None, - bookmark_on_input: bool = True, bookmark_on_response: bool = True, ) -> ChatServerState: """ @@ -300,8 +313,6 @@ def chat_mod_server( ``client`` argument; if so, a fresh clone of ``client`` (with empty turn history) is passed to it. The callable is re-invoked each time ``{id}_greeting_requested`` fires (on first view and after ``clear()``). - bookmark_on_input - Whether to trigger a bookmark when the user submits a message. bookmark_on_response Whether to trigger a bookmark when the assistant finishes responding. @@ -328,7 +339,6 @@ def _server( session: Any, client: Any, greeting: Any, - bookmark_on_input: bool, bookmark_on_response: bool, ) -> ChatServerState: chat = Chat("chat") @@ -430,9 +440,10 @@ async def _clear( if client_history == "clear": client_ref[0].set_turns([]) elif client_history == "set": - client_ref[0].set_turns([]) + client_ref[0].set_turns(_messages_to_turns(normalized)) elif client_history == "append": - pass + existing = client_ref[0].get_turns() + client_ref[0].set_turns(existing + _messages_to_turns(normalized)) # "keep" does nothing _last_turn.set(None) @@ -486,6 +497,5 @@ async def _set_initial_greeting() -> None: id, client=client, greeting=greeting, - bookmark_on_input=bookmark_on_input, bookmark_on_response=bookmark_on_response, ) diff --git a/pkg-py/tests/playwright/chat/chat_module/test_chat_module.py b/pkg-py/tests/playwright/chat/chat_module/test_chat_module.py index 473112d0..dbc42bca 100644 --- a/pkg-py/tests/playwright/chat/chat_module/test_chat_module.py +++ b/pkg-py/tests/playwright/chat/chat_module/test_chat_module.py @@ -12,15 +12,14 @@ def test_chat_module_renders(page: Page, local_app: ShinyAppProc) -> None: expect(chat.loc).to_be_visible(timeout=30_000) -def test_chat_module_cancel_button_present( +def test_chat_module_cancel_enabled( page: Page, local_app: ShinyAppProc ) -> None: page.goto(local_app.url) chat = ChatController(page, "chatmod-chat") expect(chat.loc).to_be_visible(timeout=30_000) - cancel_btn = chat.loc.locator(".shiny-chat-btn-cancel") - expect(cancel_btn).to_be_hidden() + expect(chat.loc).to_have_attribute("enable-cancel", "") def test_chat_module_submit_and_receive_response( From b2e620344ce7e302878268c5ea1d024d828780e2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 14:55:25 -0400 Subject: [PATCH 06/13] fix(py): Await `stream_async()` in chat module server `chatlas.Chat.stream_async()` is an async def returning an AsyncGenerator, so it must be awaited. The missing await caused a TypeError in `wrap_async_iterable` at runtime. --- pkg-py/src/shinychat/_chat_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg-py/src/shinychat/_chat_module.py b/pkg-py/src/shinychat/_chat_module.py index 072ccd47..9111cffb 100644 --- a/pkg-py/src/shinychat/_chat_module.py +++ b/pkg-py/src/shinychat/_chat_module.py @@ -382,7 +382,7 @@ def _set_client(new_client: Any, sync: bool = True) -> None: async def _handle_submit(user_input: str) -> None: _last_input.set(user_input) _status.set("streaming") - response = client_ref[0].stream_async(user_input, content="all") + response = await client_ref[0].stream_async(user_input, content="all") await chat.append_message_stream(response) @reactive.effect From 3263f9c0144315f0406bb17ecc60c317608d02b2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 15:07:25 -0400 Subject: [PATCH 07/13] docs(py): Improve chat module docstrings - Note cancel is enabled by default in chat_mod_ui() - Fix last_turn() return type to Optional[Any] - Note client property is not reactive - Tighten append() role type to Literal["assistant", "user"] - Tighten clear() messages type to list[Union[dict[str, str], str]] - Replace internal input name with behavioral description in clear() - Note async callable support in chat_mod_server() greeting param --- pkg-py/src/shinychat/_chat_module.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/pkg-py/src/shinychat/_chat_module.py b/pkg-py/src/shinychat/_chat_module.py index 9111cffb..8530786e 100644 --- a/pkg-py/src/shinychat/_chat_module.py +++ b/pkg-py/src/shinychat/_chat_module.py @@ -65,7 +65,8 @@ def chat_mod_ui( UI for a batteries-included chat module. Use with :func:`~shinychat.chat_mod_server` to create a complete chat interface - that automatically wires a chatlas client to the chat UI. + that automatically wires a chatlas client to the chat UI. Stream cancellation is + enabled by default. Parameters ---------- @@ -141,7 +142,7 @@ def last_input(self) -> Optional[str]: """ return self._last_input_rv() - def last_turn(self) -> Any: + def last_turn(self) -> Optional[Any]: """ Reactively read the last assistant turn. @@ -161,7 +162,8 @@ def status(self) -> Literal["streaming", "idle"]: @property def client(self) -> chatlas.Chat[Any, Any]: """ - The current chatlas client. + The current chatlas client. Not reactive — reading this in a reactive context + does not create a dependency. """ return self._client_ref[0] @@ -195,7 +197,7 @@ async def append( self, response: Any, *, - role: str = "assistant", + role: Literal["assistant", "user"] = "assistant", icon: Optional[Union[HTML, Tag, TagList]] = None, ) -> None: """ @@ -222,7 +224,7 @@ async def append( async def clear( self, - messages: Optional[list[Any]] = None, + messages: Optional[list[Union[dict[str, str], str]]] = None, greeting: bool = False, client_history: Literal["clear", "set", "append", "keep"] = "clear", ) -> None: @@ -236,8 +238,9 @@ async def clear( dict with ``role`` and ``content`` keys, or a string (treated as an assistant message). greeting - If ``True``, also clear the greeting, which causes the - ``{id}_greeting_requested`` input to fire again. + If ``True``, also clear the greeting. If the module was created with a + greeting function, the function will be called again to generate a new + greeting. client_history How to handle the chatlas client's turn history: @@ -309,10 +312,10 @@ def chat_mod_server( - A static string, :class:`~htmltools.HTML`, :class:`~htmltools.TagList`, or :func:`~shinychat.chat_greeting`. - - A callable that returns a greeting. The callable may optionally accept a - ``client`` argument; if so, a fresh clone of ``client`` (with empty turn - history) is passed to it. The callable is re-invoked each time - ``{id}_greeting_requested`` fires (on first view and after ``clear()``). + - A callable (sync or async) that returns a greeting. The callable may + optionally accept a ``client`` argument; if so, a fresh clone of ``client`` + (with empty turn history) is passed to it. The callable is re-invoked each + time the chat needs a greeting (on first view and after ``clear()``). bookmark_on_response Whether to trigger a bookmark when the assistant finishes responding. From 1af976c786bd347f17081e69ab6fcdd4d7ca03fc Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 15:08:23 -0400 Subject: [PATCH 08/13] docs(py): Add chat module section to Get Started page Add a "Chat module" section showing chat_mod_ui/chat_mod_server usage and a note that chat_mod_server handles cancellation automatically. --- pkg-py/docs/index.qmd | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/pkg-py/docs/index.qmd b/pkg-py/docs/index.qmd index 8097c4d1..a6933392 100644 --- a/pkg-py/docs/index.qmd +++ b/pkg-py/docs/index.qmd @@ -121,6 +121,29 @@ Key points: - Pass the controller to `stream_async(controller=ctrl)` so chatlas can honour the cancellation signal. - The cancel input fires as `input._cancel` (e.g. `input.chat_cancel`). Observe it with `@reactive.event` to call `ctrl.cancel()`. This is needed in both Express and Core apps. - Partial responses are automatically preserved in chat history by chatlas when a stream is cancelled. +- If you use `chat_mod_server()`, cancellation is handled automatically — no manual wiring needed. + +## Chat module + +For Shiny Core apps that use [chatlas](https://posit-dev.github.io/chatlas/), `chat_mod_ui()` and `chat_mod_server()` provide a batteries-included chat interface that handles streaming, cancellation, and bookmarking automatically. + +```{.python file="app.py"} +from shiny import App, ui +from chatlas import ChatAnthropic +from shinychat import chat_mod_ui, chat_mod_server + +app_ui = ui.page_fillable( + chat_mod_ui("chat"), +) + +def server(input, output, session): + client = ChatAnthropic(system_prompt="You are a helpful assistant.") + chat = chat_mod_server("chat", client=client) + +app = App(app_ui, server) +``` + +The returned `ChatServerState` object provides reactive accessors like `chat.status()` and `chat.last_turn()`, along with methods like `chat.set_client()` for swapping models mid-session. See the [API reference](api/index.qmd) for the full interface. ## Learn more From c92366736d51d2786407721b76ec38217b27bfe2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 15:14:24 -0400 Subject: [PATCH 09/13] feat(py): Add `greeting` parameter to `chat_mod_ui()` Passes static greeting values through to `chat_ui()`, matching the R package where greeting flows through `...`. --- pkg-py/src/shinychat/_chat_module.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg-py/src/shinychat/_chat_module.py b/pkg-py/src/shinychat/_chat_module.py index 8530786e..8cf7f128 100644 --- a/pkg-py/src/shinychat/_chat_module.py +++ b/pkg-py/src/shinychat/_chat_module.py @@ -53,6 +53,7 @@ def chat_mod_ui( messages: Optional[ Iterable[Union[str, TagChild, ChatMessageDict, ChatMessage, Any]] ] = None, + greeting: Optional[Union[str, HTML, Tag, TagList, ChatGreeting]] = None, placeholder: str = "Enter a message...", width: CssUnit = "min(680px, 100%)", height: CssUnit = "auto", @@ -74,6 +75,11 @@ def chat_mod_ui( The module ID. Must match the ``id`` passed to :func:`~shinychat.chat_mod_server`. messages Initial messages to display in the chat. + greeting + A static greeting to display at the top of the chat before any conversation + messages. Can be a markdown string or a :func:`~shinychat.chat_greeting` object. + For dynamic or streaming greetings, use the ``greeting`` parameter of + :func:`~shinychat.chat_mod_server` instead. placeholder Placeholder text for the chat input. width @@ -96,6 +102,7 @@ def chat_mod_ui( return chat_ui( ResolvedId(f"{resolved}-chat"), messages=messages, + greeting=greeting, enable_cancel=True, placeholder=placeholder, width=width, From a0f3f5eb606628506d132c0617471127ed7fe54c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 15:19:06 -0400 Subject: [PATCH 10/13] style(py): Fix lint and format issues in chat module files --- pkg-py/src/shinychat/_chat_module.py | 48 +++++++++++++------ .../tests/playwright/chat/chat_module/app.py | 3 +- pkg-py/tests/pytest/test_chat_module.py | 8 ++-- 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/pkg-py/src/shinychat/_chat_module.py b/pkg-py/src/shinychat/_chat_module.py index 8cf7f128..6f7cc669 100644 --- a/pkg-py/src/shinychat/_chat_module.py +++ b/pkg-py/src/shinychat/_chat_module.py @@ -224,7 +224,9 @@ async def append( await self._chat.append_message_stream(response, icon=stream_icon) else: msg: ChatMessageDict = { - "content": str(response) if not isinstance(response, (HTML, Tag, TagList)) else response, # type: ignore[typeddict-item] + "content": str(response) + if not isinstance(response, (HTML, Tag, TagList)) + else response, # type: ignore[typeddict-item] "role": role, # type: ignore[typeddict-item] } await self._chat.append_message(msg, icon=icon) @@ -274,7 +276,9 @@ async def set_greeting( """ await self._set_greeting_fn(greeting) - def set_client(self, new_client: chatlas.Chat[Any, Any], *, sync: bool = True) -> None: + def set_client( + self, new_client: chatlas.Chat[Any, Any], *, sync: bool = True + ) -> None: """ Replace the chatlas client. @@ -356,8 +360,12 @@ def _server( _last_input: reactive.Value[Optional[str]] = reactive.Value(None) _last_turn: reactive.Value[Any] = reactive.Value(None) - _status: reactive.Value[Literal["streaming", "idle"]] = reactive.Value("idle") - _pending_swap: reactive.Value[Optional[dict[str, Any]]] = reactive.Value(None) + _status: reactive.Value[Literal["streaming", "idle"]] = reactive.Value( + "idle" + ) + _pending_swap: reactive.Value[Optional[dict[str, Any]]] = ( + reactive.Value(None) + ) def _swap_client(new_client: Any, sync: bool) -> None: if sync: @@ -392,7 +400,9 @@ def _set_client(new_client: Any, sync: bool = True) -> None: async def _handle_submit(user_input: str) -> None: _last_input.set(user_input) _status.set("streaming") - response = await client_ref[0].stream_async(user_input, content="all") + response = await client_ref[0].stream_async( + user_input, content="all" + ) await chat.append_message_stream(response) @reactive.effect @@ -434,18 +444,24 @@ async def _clear( if isinstance(msg, str): normalized.append({"role": "assistant", "content": msg}) elif isinstance(msg, dict): - normalized.append({ - "role": msg.get("role", "assistant"), - "content": str(msg.get("content", "")), - }) + normalized.append( + { + "role": msg.get("role", "assistant"), + "content": str(msg.get("content", "")), + } + ) else: - normalized.append({"role": "assistant", "content": str(msg)}) + normalized.append( + {"role": "assistant", "content": str(msg)} + ) for msg in normalized: - await chat.append_message({ - "content": msg["content"], - "role": msg["role"], - }) + await chat.append_message( + { + "content": msg["content"], + "role": msg["role"], + } + ) if client_history == "clear": client_ref[0].set_turns([]) @@ -453,7 +469,9 @@ async def _clear( client_ref[0].set_turns(_messages_to_turns(normalized)) elif client_history == "append": existing = client_ref[0].get_turns() - client_ref[0].set_turns(existing + _messages_to_turns(normalized)) + client_ref[0].set_turns( + existing + _messages_to_turns(normalized) + ) # "keep" does nothing _last_turn.set(None) diff --git a/pkg-py/tests/playwright/chat/chat_module/app.py b/pkg-py/tests/playwright/chat/chat_module/app.py index ba8a034a..c951c0c1 100644 --- a/pkg-py/tests/playwright/chat/chat_module/app.py +++ b/pkg-py/tests/playwright/chat/chat_module/app.py @@ -2,8 +2,7 @@ from typing import Any, Optional -from shiny import App, Inputs, Outputs, Session, reactive, render, ui - +from shiny import App, Inputs, Outputs, Session, render, ui from shinychat import chat_mod_server, chat_mod_ui diff --git a/pkg-py/tests/pytest/test_chat_module.py b/pkg-py/tests/pytest/test_chat_module.py index b233ce15..ddf8ccf1 100644 --- a/pkg-py/tests/pytest/test_chat_module.py +++ b/pkg-py/tests/pytest/test_chat_module.py @@ -1,16 +1,14 @@ from __future__ import annotations import inspect -from typing import Any, cast +from typing import cast import pytest -from htmltools import HTML, Tag, TagList, tags +from htmltools import HTML, Tag, tags from shiny import Session from shiny.module import ResolvedId from shiny.session import session_context - -from shinychat import ChatServerState, chat_mod_ui, chat_mod_server - +from shinychat import ChatServerState, chat_mod_ui # --------------------------------------------------------------------------- # Helpers From 233c5d7abee39772a112ebac09aab8866792214a Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 15:21:57 -0400 Subject: [PATCH 11/13] fix(py): Rename test files to avoid pytest import collision The pre-existing test_chat_module.py in chat/module/ collides with new test files of the same name. Rename to test_chat_mod.py (unit) and test_chat_mod_e2e.py (Playwright) so pytest can collect all three. --- .../chat_module/{test_chat_module.py => test_chat_mod_e2e.py} | 0 pkg-py/tests/pytest/{test_chat_module.py => test_chat_mod.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename pkg-py/tests/playwright/chat/chat_module/{test_chat_module.py => test_chat_mod_e2e.py} (100%) rename pkg-py/tests/pytest/{test_chat_module.py => test_chat_mod.py} (100%) diff --git a/pkg-py/tests/playwright/chat/chat_module/test_chat_module.py b/pkg-py/tests/playwright/chat/chat_module/test_chat_mod_e2e.py similarity index 100% rename from pkg-py/tests/playwright/chat/chat_module/test_chat_module.py rename to pkg-py/tests/playwright/chat/chat_module/test_chat_mod_e2e.py diff --git a/pkg-py/tests/pytest/test_chat_module.py b/pkg-py/tests/pytest/test_chat_mod.py similarity index 100% rename from pkg-py/tests/pytest/test_chat_module.py rename to pkg-py/tests/pytest/test_chat_mod.py From 1ef7a544fe0c376dd869480a2440b61d94547558 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 15:27:04 -0400 Subject: [PATCH 12/13] fix(py): Make MockChat.stream_async a coroutine returning async generator Matches the real chatlas.Chat.stream_async signature so that `await client.stream_async(...)` works correctly in chat_mod_server. --- pkg-py/tests/playwright/chat/chat_module/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg-py/tests/playwright/chat/chat_module/app.py b/pkg-py/tests/playwright/chat/chat_module/app.py index c951c0c1..5680acbe 100644 --- a/pkg-py/tests/playwright/chat/chat_module/app.py +++ b/pkg-py/tests/playwright/chat/chat_module/app.py @@ -12,7 +12,7 @@ def __init__(self): self._system_prompt: str = "" self._tools: list[Any] = [] - def stream_async(self, user_input: str, content: str = "all"): + async def stream_async(self, user_input: str, content: str = "all"): async def _stream(): for word in f"Echo: {user_input}".split(): yield word + " " From 8d591451e6f2d3b4d6f5fec5411f2d886f78d875 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 26 May 2026 15:31:07 -0400 Subject: [PATCH 13/13] fix(py): Suppress pyright type error for MockChat in test app --- pkg-py/tests/playwright/chat/chat_module/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg-py/tests/playwright/chat/chat_module/app.py b/pkg-py/tests/playwright/chat/chat_module/app.py index 5680acbe..697b89c0 100644 --- a/pkg-py/tests/playwright/chat/chat_module/app.py +++ b/pkg-py/tests/playwright/chat/chat_module/app.py @@ -53,7 +53,7 @@ def get_last_turn(self) -> Optional[Any]: def server(input: Inputs, output: Outputs, session: Session): client = MockChat() - state = chat_mod_server("chatmod", client=client) + state = chat_mod_server("chatmod", client=client) # pyright: ignore[reportArgumentType] @output @render.text