Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.1] - 2026-08-14

### Bug fixes

* Conversation history now rejects records written with unsupported future schema versions instead of attempting an unsafe downgrade.

* Fixed a race between the chat greeting and conversation history restore: reloading a page that restored a previous conversation could briefly flash the app's greeting, and starting a new chat after a session began with a restored conversation could fail to show any greeting at all. Greeting resolution now defers to history's own restore decision instead of racing the client's independent greeting request.

* Restoring a bookmark that contains a malformed message (for instance one written by an incompatible shinychat version) now warns and skips just that message, instead of raising and dropping every message after it.

* Fixed conversation history and bookmarks failing to serialize a chatlas `ContentToolResult` when its supported dictionary-form `extra["display"]` contains HTML. Dictionary displays are now normalized through `ToolResultDisplay` before JSON serialization. (Related to #295)

## [0.6.0] - 2026-07-06

### New features
Expand Down
32 changes: 26 additions & 6 deletions pkg-py/src/shinychat/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import inspect
import json
import re
import warnings
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import (
Expand Down Expand Up @@ -472,9 +473,12 @@ async def _on_slash_command():
self._setup_client(client)

if greeting is not None:
from ._chat_client import setup_greeting
if self.history._controller is not None:
self.history.setup_greeting(greeting)
else:
from ._chat_client import setup_greeting

setup_greeting(self, greeting, self._session)
setup_greeting(self, greeting, self._session)

def _setup_client(
self,
Expand Down Expand Up @@ -1363,10 +1367,26 @@ async def _restore_bookmark_message(self, message_dict: Any) -> None:
try:
stored = StoredMessage.model_validate(message_dict)
except ValidationError as e:
raise ValueError(
"Cannot restore bookmark message: invalid or missing fields "
"(bookmark likely written by an incompatible shinychat version)."
) from e
# Skip rather than raise: raising here would abort the caller's
# restore loop, silently dropping every message after this one
# too (Shiny's on_restore error handling only shows a banner, it
# doesn't resume the loop).
#
# include_input=False: the default error string embeds the
# offending value, which for a chat message is arbitrary (and
# possibly sensitive) message content -- keep the warning to
# locations/reasons only.
details = "; ".join(
f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}"
for err in e.errors(include_input=False)
)
warnings.warn(
"Skipping malformed bookmarked chat message: invalid or "
"missing fields (bookmark likely written by an incompatible "
f"shinychat version). {details}",
stacklevel=2,
)
return
self._store_message(stored)
await self._send_append_message(stored)

Expand Down
4 changes: 3 additions & 1 deletion pkg-py/src/shinychat/_chat_bookmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
runtime_checkable,
)

from ._chatlas_serialization import serialize_chatlas_turn

if TYPE_CHECKING:
from chatlas import Chat
from htmltools import Tagified
Expand Down Expand Up @@ -85,7 +87,7 @@ async def get_state() -> Jsonifiable:
turns: list[Turn[Any]] = client.get_turns()
return {
"version": 1,
"turns": [turn.model_dump(mode="json") for turn in turns],
"turns": [serialize_chatlas_turn(turn) for turn in turns],
}

return get_state
Expand Down
45 changes: 27 additions & 18 deletions pkg-py/src/shinychat/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,32 @@ def messages_to_turns(
return turns


async def resolve_greeting(
chat: "Chat",
greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any]",
) -> None:
"""Resolve `greeting` (static content or a callable) and set it on `chat`."""
from htmltools import HTML, Tag, TagList

from ._chat_types import ChatGreeting

if isinstance(greeting, (str, HTML, Tag, TagList, ChatGreeting)):
return await chat.set_greeting(greeting)

sig = inspect.signature(greeting)
if "client" in sig.parameters and chat.client is not None:
client_copy = copy.deepcopy(chat.client.value)
client_copy.set_turns([])
result = greeting(client=client_copy)
else:
result = greeting()

if inspect.isawaitable(result):
result = await result

await chat.set_greeting(result) # type: ignore[arg-type]


def setup_greeting(
chat: "Chat",
greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None",
Expand All @@ -195,33 +221,16 @@ def setup_greeting(
if greeting is None:
return

from htmltools import HTML, Tag, TagList
from shiny import reactive
from shiny.module import ResolvedId
from shiny.session import session_context

from ._chat_types import ChatGreeting

with session_context(session):
greeting_requested_id = ResolvedId(f"{chat.id}_greeting_requested")

@reactive.effect
@reactive.event(session.input[greeting_requested_id])
async def _on_greeting_requested() -> None:
if isinstance(greeting, (str, HTML, Tag, TagList, ChatGreeting)):
return await chat.set_greeting(greeting)

sig = inspect.signature(greeting)
if "client" in sig.parameters and chat.client is not None:
client_copy = copy.deepcopy(chat.client.value)
client_copy.set_turns([])
result = greeting(client=client_copy)
else:
result = greeting()

if inspect.isawaitable(result):
result = await result

await chat.set_greeting(result) # type: ignore[arg-type]
await resolve_greeting(chat, greeting)

chat._effects.append(_on_greeting_requested)
36 changes: 36 additions & 0 deletions pkg-py/src/shinychat/_chatlas_serialization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from chatlas import Turn


def serialize_chatlas_turn(turn: Turn[Any]) -> dict[str, Any]:
"""Serialize a chatlas turn after normalizing supported rich displays."""
from chatlas import ContentToolResult

from ._chat_normalize_chatlas import ToolResultDisplay

normalized = turn
for index, content in enumerate(turn.contents):
if not isinstance(content, ContentToolResult):
continue
extra = content.extra
if not isinstance(extra, dict) or not isinstance(
extra.get("display"), dict
):
continue

if normalized is turn:
normalized = turn.model_copy(deep=True)
normalized_content = normalized.contents[index]
assert isinstance(normalized_content, ContentToolResult)
normalized_content.extra = dict(normalized_content.extra)
display = normalized_content.extra["display"]
normalized_content.extra["display"] = {
**display,
**ToolResultDisplay(**display).model_dump(mode="json"),
}

return normalized.model_dump(mode="json")
73 changes: 63 additions & 10 deletions pkg-py/src/shinychat/_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@
fallback_title,
generate_title,
)
from ._history_types import ConversationRecord, new_conversation_record
from ._history_types import (
ConversationRecord,
check_schema_version,
new_conversation_record,
)

if TYPE_CHECKING:
from htmltools import HTML, Tag, TagList
from shiny.module import ResolvedId

from ._chat import Chat
from ._chat_types import ChatGreeting


@dataclasses.dataclass(frozen=True)
Expand Down Expand Up @@ -232,6 +238,9 @@ def __init__(
) = None
# Internal hook: fired before a conversation is removed from the store.
self.on_evict: Callable[[str], Awaitable[None]] | None = None
# Internal hook: fired whenever it is known whether the active
# conversation is a restore (True) or a fresh/new one (False).
self.on_settled: Callable[[bool], Awaitable[None]] | None = None
self.max_store_bytes: int | None = max_store_bytes
self._title_task: asyncio.Task[None] | None = None
# replay_ui awaits per message, so on_response can fire mid-replay;
Expand All @@ -242,6 +251,14 @@ def __init__(
self._suppress_next_save: bool = False
self._over_budget_warned: bool = False

async def _get_record(
self, partition: ConversationPartition, conv_id: str
) -> ConversationRecord | None:
record = await self.store.get(partition, conv_id)
if record is not None:
check_schema_version(record.schema_version)
return record

# -- save -----------------------------------------------------------

async def on_response(self) -> None:
Expand Down Expand Up @@ -340,6 +357,11 @@ def cancel_pending(self) -> None:
if self._title_task is not None and not self._title_task.done():
self._title_task.cancel()

async def notify_settled(self, restored: bool) -> None:
"""Called whenever the active conversation's restore state is known."""
if self.on_settled is not None:
await self.on_settled(restored)

async def _evict_one(self, conv_id: str) -> None:
assert self.partition is not None
if self.on_evict is not None:
Expand Down Expand Up @@ -402,7 +424,7 @@ async def switch_to(self, conv_id: str) -> None:
return
# Load BEFORE mutating anything: a failed load must leave the
# current conversation untouched.
target = await self.store.get(self.partition, conv_id)
target = await self._get_record(self.partition, conv_id)
if target is None:
raise RuntimeError(f"Conversation {conv_id!r} no longer exists.")

Expand All @@ -427,13 +449,15 @@ async def new_chat(self) -> None:
self.record = None
if self.on_active_id_change is not None:
await self.on_active_id_change(None)
await self.notify_settled(False)
await self.send_history_update()

async def replay_ui(self, record: ConversationRecord) -> None:
self._is_replaying = True
self._suppress_next_save = True
try:
await self.chat.clear_messages()
await self.chat.set_greeting(None)
for node_id in record.path_node_ids():
node = record.nodes[node_id]
stored = node.ui or [
Expand Down Expand Up @@ -466,7 +490,7 @@ async def rename(self, conv_id: str, title: str) -> None:
record = (
self.record
if self.record is not None and self.record.id == conv_id
else await self.store.get(self.partition, conv_id)
else await self._get_record(self.partition, conv_id)
)
if record is None:
return
Expand Down Expand Up @@ -547,6 +571,7 @@ def __init__(
) -> None:
self._chat = chat
self._started: bool = False
self._controller: HistoryController | None = None
self._save_callbacks: "list[Callable[[dict[str, Any]], None]]" = []
self._restore_callbacks: "list[Callable[[dict[str, Any]], None]]" = []
cfg = config if config is not None else HistoryOptions()
Expand Down Expand Up @@ -616,6 +641,23 @@ def _(values):
self._restore_callbacks.append(fn)
return fn

def setup_greeting(
self,
greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any]",
) -> None:
"""Resolve a greeting after history determines whether it restored."""
from ._chat_client import resolve_greeting

chat = self._chat
controller = self._controller
assert controller is not None

async def _on_settled(restored: bool) -> None:
if not restored:
await resolve_greeting(chat, greeting)

controller.on_settled = _on_settled

def _start(self) -> None:
chat = self._chat
chat_client = chat.client
Expand Down Expand Up @@ -657,6 +699,7 @@ def _start(self) -> None:
restore_callbacks=self._restore_callbacks,
max_store_bytes=max_store_bytes,
)
self._controller = controller

if restore_mode == "url":

Expand Down Expand Up @@ -726,7 +769,7 @@ async def _on_evict(conv_id: str) -> None:
if controller.partition is None:
rec = None
else:
rec = await controller.store.get(
rec = await controller._get_record(
controller.partition, conv_id
)
state_id = (
Expand Down Expand Up @@ -818,9 +861,13 @@ async def _init_history():
restored_conv_id = str(raw_id) if raw_id else None

if restored_conv_id is not None:
target = await controller.store.get(
controller.partition, restored_conv_id
)
try:
target = await controller._get_record(
controller.partition, restored_conv_id
)
except Exception as e:
await notify_error("Could not load conversation", e)
target = None
if target is not None:
adapter.set_turns_json(target.path_turns())
await controller.replay_ui(target)
Expand All @@ -829,6 +876,7 @@ async def _init_history():
controller.record = target
await controller.send_history_update()
initialized = True
await controller.notify_settled(True)
return

# Priority 2: restore from the mode-specific ID source.
Expand All @@ -848,16 +896,21 @@ async def _init_history():
current_id = None

if current_id:
pointed = await controller.store.get(
controller.partition, current_id
)
try:
pointed = await controller._get_record(
controller.partition, current_id
)
except Exception as e:
await notify_error("Could not load conversation", e)
pointed = None
if pointed is not None:
adapter.set_turns_json(pointed.path_turns())
await controller.replay_ui(pointed)
controller._restore_app_state(pointed.values or {})
controller.record = pointed
await controller.send_history_update()
initialized = True
await controller.notify_settled(controller.record is not None)

@reactive.effect
@reactive.event(chat.messages, ignore_init=True)
Expand Down
Loading
Loading