From 3cb2a2a1f5f6a55957380600cff6574dd0d95ffb Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 May 2026 17:52:26 -0500 Subject: [PATCH 1/8] Use ContentThinkingDelta with phase for streaming thinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ContentThinking accumulator pattern with ContentThinkingDelta's phase-based inline construction. The phase property ("start", "body", "end") lets the server build stored messages with tags in a single pass — no accumulator or finally-block reconstruction needed. - R: add ContentThinkingDelta dispatch in contents_shinychat - Python: replace _is_content_thinking + accumulator with _is_content_thinking_delta + phase-based message building - Remove _current_stream_thinking field entirely Depends on posit-dev/chatlas#299. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg-py/src/shinychat/_chat.py | 27 ++++++++++--------------- pkg-py/src/shinychat/_chat_normalize.py | 8 ++++---- pkg-r/R/contents_shinychat.R | 4 ++++ 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index a90fe557..ce47ee37 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -132,14 +132,14 @@ ] -def _is_content_thinking(msg: Any) -> bool: - """Check if a message is a ContentThinking object from chatlas.""" +def _is_content_thinking_delta(msg: Any) -> bool: + """Check if a message is a ContentThinkingDelta object from chatlas.""" try: from chatlas.types import ( - ContentThinking, # pyright: ignore[reportAttributeAccessIssue] + ContentThinkingDelta, # pyright: ignore[reportAttributeAccessIssue] ) - return isinstance(msg, ContentThinking) + return isinstance(msg, ContentThinkingDelta) except ImportError: return False @@ -291,7 +291,6 @@ def __init__( # Chunked messages get accumulated (using this property) before changing state self._current_stream_message: str = "" - self._current_stream_thinking: str = "" self._current_stream_deps: list[HTMLDependency] = [] self._current_stream_id: str | None = None self._pending_messages: list[PendingMessage] = [] @@ -829,7 +828,6 @@ async def _append_message_chunk( if chunk == "end": self._current_stream_id = None self._current_stream_message = "" - self._current_stream_thinking = "" self._current_stream_deps = [] self._message_stream_checkpoint = "" self._message_stream_deps_checkpoint = [] @@ -971,27 +969,24 @@ async def _append_message_stream( try: async for msg in message: - if _is_content_thinking(msg): - thinking_text = msg.thinking if hasattr(msg, "thinking") else str(msg) - self._current_stream_thinking += thinking_text + if _is_content_thinking_delta(msg): + thinking_text: str = msg.thinking thinking_msg = ChatMessage(content=thinking_text, role="assistant") await self._send_append_message( thinking_msg, chunk=True, content_type_override="thinking", ) + if msg.phase == "start": + self._current_stream_message += "\n" + self._current_stream_message += thinking_text + if msg.phase == "end": + self._current_stream_message += "\n\n\n" continue await self._append_message_chunk(msg, chunk=True, stream_id=id) return self._current_stream_message finally: - if self._current_stream_thinking: - self._current_stream_message = ( - "\n" - + self._current_stream_thinking - + "\n\n\n" - + self._current_stream_message - ) await self._append_message_chunk(empty, chunk="end", stream_id=id) await self._flush_pending_messages() diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index b2d09c0b..84291fd4 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -166,19 +166,19 @@ def _(chunk: ContentToolResult): def _(chunk: ContentToolResult): return message_content(chunk) - # ContentThinking is handled directly in _append_message_stream, + # ContentThinkingDelta is handled directly in _append_message_stream, # but register it here so message_content_chunk doesn't raise for it. try: from chatlas.types import ( - ContentThinking, # pyright: ignore[reportAttributeAccessIssue] + ContentThinkingDelta, # pyright: ignore[reportAttributeAccessIssue] ) @message_content.register - def _(message: ContentThinking): + def _(message: ContentThinkingDelta): return ChatMessage(content=message.thinking) @message_content_chunk.register - def _(chunk: ContentThinking): + def _(chunk: ContentThinkingDelta): return ChatMessage(content=chunk.thinking) except ImportError: pass diff --git a/pkg-r/R/contents_shinychat.R b/pkg-r/R/contents_shinychat.R index 50e98c68..646c9a56 100644 --- a/pkg-r/R/contents_shinychat.R +++ b/pkg-r/R/contents_shinychat.R @@ -140,6 +140,10 @@ S7::method(contents_shinychat, ellmer::ContentThinking) <- function(content) { structure(content@thinking, class = "shinychat_thinking") } +S7::method(contents_shinychat, ellmer::ContentThinkingDelta) <- function(content) { + structure(content@thinking, class = "shinychat_thinking") +} + new_tool_card <- function(type, request_id, tool_name, ...) { type <- arg_match(type, c("request", "result")) From 457b9988bfe1061d2124797f936a8d190ecec44f Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 May 2026 18:34:22 -0500 Subject: [PATCH 2/8] Register both ContentThinking and ContentThinkingDelta in normalizer ContentThinking still appears in non-streaming contexts like bookmark restore, so it needs to remain registered alongside ContentThinkingDelta. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg-py/src/shinychat/_chat_normalize.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index 84291fd4..a6d7f9c5 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -166,13 +166,23 @@ def _(chunk: ContentToolResult): def _(chunk: ContentToolResult): return message_content(chunk) + # ContentThinking appears in non-streaming contexts (e.g., bookmark restore). # ContentThinkingDelta is handled directly in _append_message_stream, # but register it here so message_content_chunk doesn't raise for it. try: from chatlas.types import ( + ContentThinking, # pyright: ignore[reportAttributeAccessIssue] ContentThinkingDelta, # pyright: ignore[reportAttributeAccessIssue] ) + @message_content.register + def _(message: ContentThinking): + return ChatMessage(content=message.thinking) + + @message_content_chunk.register + def _(chunk: ContentThinking): + return ChatMessage(content=chunk.thinking) + @message_content.register def _(message: ContentThinkingDelta): return ChatMessage(content=message.thinking) From b1c6e8ea86d3d867eadf6cbd96f8fdfdaaa20ca1 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 May 2026 18:38:23 -0500 Subject: [PATCH 3/8] Bump ellmer to >= 0.4.1.9000 and chatlas to >= 0.16.0 Both packages need versions that export ContentThinkingDelta. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg-r/DESCRIPTION | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg-r/DESCRIPTION b/pkg-r/DESCRIPTION index 195f012b..3b2c4fb2 100644 --- a/pkg-r/DESCRIPTION +++ b/pkg-r/DESCRIPTION @@ -24,7 +24,7 @@ Imports: bslib, cli, coro, - ellmer (>= 0.4.0.9000), + ellmer (>= 0.4.1.9000), fastmap, htmltools, jsonlite, diff --git a/pyproject.toml b/pyproject.toml index be0730a9..30fb44a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ Changelog = "https://github.com/posit-dev/shinychat/blob/main/pkg-py/CHANGELOG.m [project.optional-dependencies] providers = [ "anthropic;python_version>='3.11'", - "chatlas[mcp]>=0.15.0", + "chatlas[mcp]>=0.16.0", "pydantic", "google-genai", "langchain-core>=1.0.0", From 133e883578d9cfaa780651cac8b59c0b7e3bfcb8 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 May 2026 18:51:37 -0500 Subject: [PATCH 4/8] Guard ContentThinkingDelta method registration on class existence The method registration fails at load time if the installed ellmer doesn't export ContentThinkingDelta yet. Guard with exists() so shinychat can still load with older ellmer versions. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg-r/R/contents_shinychat.R | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg-r/R/contents_shinychat.R b/pkg-r/R/contents_shinychat.R index 646c9a56..8ff8a70d 100644 --- a/pkg-r/R/contents_shinychat.R +++ b/pkg-r/R/contents_shinychat.R @@ -140,8 +140,10 @@ S7::method(contents_shinychat, ellmer::ContentThinking) <- function(content) { structure(content@thinking, class = "shinychat_thinking") } -S7::method(contents_shinychat, ellmer::ContentThinkingDelta) <- function(content) { - structure(content@thinking, class = "shinychat_thinking") +if (exists("ContentThinkingDelta", envir = asNamespace("ellmer"))) { + S7::method(contents_shinychat, ellmer::ContentThinkingDelta) <- function(content) { + structure(content@thinking, class = "shinychat_thinking") + } } new_tool_card <- function(type, request_id, tool_name, ...) { From 29a0548de10a11d1bda93f824113b6b2ebb9891b Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 May 2026 18:52:30 -0500 Subject: [PATCH 5/8] Revert exists() guard on ContentThinkingDelta registration CI failure is expected until ellmer ships ContentThinkingDelta. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg-r/R/contents_shinychat.R | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg-r/R/contents_shinychat.R b/pkg-r/R/contents_shinychat.R index 8ff8a70d..646c9a56 100644 --- a/pkg-r/R/contents_shinychat.R +++ b/pkg-r/R/contents_shinychat.R @@ -140,10 +140,8 @@ S7::method(contents_shinychat, ellmer::ContentThinking) <- function(content) { structure(content@thinking, class = "shinychat_thinking") } -if (exists("ContentThinkingDelta", envir = asNamespace("ellmer"))) { - S7::method(contents_shinychat, ellmer::ContentThinkingDelta) <- function(content) { - structure(content@thinking, class = "shinychat_thinking") - } +S7::method(contents_shinychat, ellmer::ContentThinkingDelta) <- function(content) { + structure(content@thinking, class = "shinychat_thinking") } new_tool_card <- function(type, request_id, tool_name, ...) { From a0a4d1c1eab322a5ac4932a802b56acaeb2135a8 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 May 2026 18:54:19 -0500 Subject: [PATCH 6/8] Use TypeGuard for is_content_thinking_delta Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg-py/src/shinychat/_chat.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index ce47ee37..5e43fa55 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -132,8 +132,7 @@ ] -def _is_content_thinking_delta(msg: Any) -> bool: - """Check if a message is a ContentThinkingDelta object from chatlas.""" +def is_content_thinking_delta(msg: object) -> "TypeGuard[chatlas.ContentThinkingDelta]": try: from chatlas.types import ( ContentThinkingDelta, # pyright: ignore[reportAttributeAccessIssue] @@ -969,7 +968,7 @@ async def _append_message_stream( try: async for msg in message: - if _is_content_thinking_delta(msg): + if is_content_thinking_delta(msg): thinking_text: str = msg.thinking thinking_msg = ChatMessage(content=thinking_text, role="assistant") await self._send_append_message( From 2c22dc4228c74a28651f1bab71e76a871d272de5 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 May 2026 18:55:33 -0500 Subject: [PATCH 7/8] Simplify thinking delta handling in streaming loop Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg-py/src/shinychat/_chat.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index 5e43fa55..de17f1e7 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -969,16 +969,14 @@ async def _append_message_stream( try: async for msg in message: if is_content_thinking_delta(msg): - thinking_text: str = msg.thinking - thinking_msg = ChatMessage(content=thinking_text, role="assistant") await self._send_append_message( - thinking_msg, + ChatMessage(content=msg.thinking, role="assistant"), chunk=True, content_type_override="thinking", ) if msg.phase == "start": self._current_stream_message += "\n" - self._current_stream_message += thinking_text + self._current_stream_message += msg.thinking if msg.phase == "end": self._current_stream_message += "\n\n\n" continue From 775bc64d5cb0d2657610a198a3e758121e1d1cb2 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 8 May 2026 09:56:43 -0500 Subject: [PATCH 8/8] Move content_type resolution out of _send_append_message Instead of computing content_type inside _send_append_message (via a content_type_override parameter), resolve it at each call site using a shared resolve_content_type() helper. This lets thinking content be detected from the original message before normalization loses the type, and handles both streaming (ContentThinkingDelta) and non-streaming (ContentThinking) paths uniformly. --- pkg-py/src/shinychat/_chat.py | 68 ++++++++++++++++------------------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index de17f1e7..d4215a81 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -128,21 +128,9 @@ ChunkOption, Literal["append", "replace"], Union[str, None], - "Union[ContentType, None]", ] -def is_content_thinking_delta(msg: object) -> "TypeGuard[chatlas.ContentThinkingDelta]": - try: - from chatlas.types import ( - ContentThinkingDelta, # pyright: ignore[reportAttributeAccessIssue] - ) - - return isinstance(msg, ContentThinkingDelta) - except ImportError: - return False - - class Chat: """ Create a chat interface. @@ -642,7 +630,7 @@ async def append_message( """ # If we're in a stream, queue the message if self._current_stream_id: - self._pending_messages.append((message, False, "append", None, None)) + self._pending_messages.append((message, False, "append", None)) return msg = message_content(message) @@ -652,6 +640,7 @@ async def append_message( self._store_message(msg) await self._send_append_message( message=msg, + content_type=resolve_content_type(message, msg.content), chunk=False, icon=icon, ) @@ -754,12 +743,11 @@ async def _append_message_chunk( stream_id: str, operation: Literal["append", "replace"] = "append", icon: HTML | Tag | TagList | None = None, - content_type_override: "ContentType | None" = None, ) -> None: # If currently we're in a *different* stream, queue the message chunk if self._current_stream_id and self._current_stream_id != stream_id: self._pending_messages.append( - (message, chunk, operation, stream_id, content_type_override) + (message, chunk, operation, stream_id) ) return @@ -818,10 +806,10 @@ async def _append_message_chunk( # Send the message to the client await self._send_append_message( message=msg, + content_type=resolve_content_type(message, msg.content), chunk=chunk, operation=operation, icon=icon, - content_type_override=content_type_override, ) finally: if chunk == "end": @@ -968,19 +956,6 @@ async def _append_message_stream( try: async for msg in message: - if is_content_thinking_delta(msg): - await self._send_append_message( - ChatMessage(content=msg.thinking, role="assistant"), - chunk=True, - content_type_override="thinking", - ) - if msg.phase == "start": - self._current_stream_message += "\n" - self._current_stream_message += msg.thinking - if msg.phase == "end": - self._current_stream_message += "\n\n\n" - continue - await self._append_message_chunk(msg, chunk=True, stream_id=id) return self._current_stream_message finally: @@ -990,7 +965,7 @@ async def _append_message_stream( async def _flush_pending_messages(self): pending = self._pending_messages self._pending_messages = [] - for msg, chunk, operation, stream_id, content_type_override in pending: + for msg, chunk, operation, stream_id in pending: if chunk is False: await self.append_message(msg) else: @@ -999,17 +974,16 @@ async def _flush_pending_messages(self): chunk=chunk, operation=operation, stream_id=cast(str, stream_id), - content_type_override=content_type_override, ) # Send a message to the UI async def _send_append_message( self, message: StoredMessage | ChatMessage, + content_type: "ContentType", chunk: ChunkOption = False, operation: Literal["append", "replace"] = "append", icon: HTML | Tag | TagList | None = None, - content_type_override: "ContentType | None" = None, ): message = self._as_stored_message(message) @@ -1018,11 +992,6 @@ async def _send_append_message( return content = message.content - content_type: ContentType = ( - content_type_override - if content_type_override is not None - else "html" if isinstance(content, HTML) else "markdown" - ) msg_payload: MessagePayload = { "role": message.role, @@ -1643,7 +1612,12 @@ async def _on_restore_ui(state: RestoreState): html_deps=message_dict.get("html_deps"), ) self._store_message(stored) - await self._send_append_message(stored) + await self._send_append_message( + stored, + content_type=resolve_content_type( + message_dict, stored.content + ), + ) def _cancel_bookmarking(): _on_bookmark_client() @@ -1926,4 +1900,22 @@ def is_tool_result(val: object) -> "TypeGuard[chatlas.ContentToolResult]": return False +def is_content_thinking(msg: object) -> bool: + try: + from chatlas.types import ( + ContentThinking, # pyright: ignore[reportAttributeAccessIssue] + ContentThinkingDelta, # pyright: ignore[reportAttributeAccessIssue] + ) + + return isinstance(msg, (ContentThinking, ContentThinkingDelta)) + except ImportError: + return False + + +def resolve_content_type(message: object, content: object) -> "ContentType": + if is_content_thinking(message): + return "thinking" + return "html" if isinstance(content, HTML) else "markdown" + + CHAT_INSTANCES: WeakValueDictionary[str, Chat] = WeakValueDictionary()