Public conversation ID, emitted on OTel spans as gen_ai.conversation.id - #343
Open
cpsievert wants to merge 16 commits into
Open
Public conversation ID, emitted on OTel spans as gen_ai.conversation.id#343cpsievert wants to merge 16 commits into
gen_ai.conversation.id#343cpsievert wants to merge 16 commits into
Conversation
Allocate the active conversation ID on first user submission, before managed model work begins, instead of at the first completed save. The ID survives retries, restores, switches, edits, and set_client(), and becomes the saved ConversationRecord$id. - HistoryController gains reactive active-ID state with shared lifecycle operations (ensure/activate/clear/seed) so record and identity never move independently. - chat_server() exposes chat$history$conversation_id() and captures the ID synchronously into the response ExtendedTask. - Each managed response is wrapped in a shinychat.response OpenTelemetry span carrying gen_ai.conversation.id, so telemetry consumers (e.g. Commons) can group model work by saved conversation. No-op without a configured provider; never blocks the model call. - new_conversation_record() accepts a preallocated id.
- set_client() only seeds the replacement controller with an unsaved draft's ID; a saved conversation's ID belongs to its stored record and is restored by init (or reallocated on next submission), never seeded. - Fix the greeting block clobbering the history_controller reactiveVal, which broke every submission (and conversation_id()) when greeting was set.
Mirror the R implementation: the active conversation ID is allocated on first user submission (before model work), retained across failure/cancellation, adopted by the first saved record, and cleared on new-chat/delete. All record/identity mutations go through shared controller operations so a record's id always equals the active ID. The ID is available reactively via chat.history.conversation_id() (None when history is disabled or the draft is empty), and each managed response is wrapped in a shinychat.response OpenTelemetry span carrying gen_ai.conversation.id (a no-op unless a provider is configured). The span wraps the response stream generator so it stays active for the full background consumption, letting chatlas spans nest beneath it.
…ailures (#307) Span start/end failures (e.g. a broken custom processor) previously propagated into the response stream despite response_span()'s guard, which only covered tracer resolution. response_span() is now a contextmanager that degrades to an untraced context on any telemetry failure while still forwarding wrapped-work exceptions to the span's __exit__ so error recording is unchanged.
Keep comments that explain why (invariants, cross-component context, non-obvious constraints); drop ones a maintainer can infer from the code itself or that duplicate rationale stated at the call site.
cpsievert
force-pushed
the
feat/stable-conversation-id
branch
from
August 25, 2026 00:51
ad286ba to
de70f76
Compare
shinychat no longer emits OpenTelemetry spans itself. Instead it hands the active conversation ID to the ellmer/chatlas client, which records it as gen_ai.conversation.id on its own invoke_agent/chat spans per the OTel GenAI semantic conventions. - R: remove the shinychat.response span and otel/otelsdk deps; set the client conversation_id binding before the model call (capability-checked until the ellmer release lands). - Python: remove _otel.py; require chatlas>=0.22.0 so the property is guaranteed and the client stays strongly typed (temporarily resolved from posit-dev/chatlas#398 via tool.uv.sources until release). - Tests: span-lifecycle tests become client-binding tests; the R mock client is now an environment so in-place assignment matches real R6 semantics.
cpsievert
commented
Aug 25, 2026
Conflicts resolved in pkg-py/src/shinychat/_history.py and pkg-r/R/chat_history.R between main's #328 (programmatic history save) and this branch's stable conversation identity work: - Record activation goes through activate_record() (which also sets the active ID and fires on_active_id_change), replacing main's direct `record <- target` / `self.record = target` assignments. - Main's deliberate reordering is preserved: the record is activated BEFORE restore callbacks run ("after it becomes active"), and main's removal of the restore_mode != "bookmark" guard on app-state restore is kept (bookmark mode now restores state too). - ChatHistory gains both this branch's conversation_id() reactive read and main's save() method. Also included, surfaced by merge verification: - fix: new_chat() announces the cleared conversation ID via on_active_id_change even when the active ID was already None/NULL. Main notified unconditionally; this branch's fire-on-change single writer dropped that, leaving a stale conversation param in the address bar in URL/bookmark restore modes (e.g. after a failed restore). Both tests failing here pre-date the merge. - test(py): delete-active test now activates the record through activate_record() instead of assigning `record` directly, which violated the record/active-ID invariant. - test(r): cover the client conversation-ID handoff for clients without the `conversation_id` binding (older ellmer), and the new_chat() cleared-ID announcement.
…explanatory comments
Older chatlas and non-chatlas clients lack the conversation_id attribute; setting it unconditionally could raise (slots/read-only property) or silently mislead. Mirror the R capability check. Also add the missing R NEWS entry for stable conversation identity (#307).
cpsievert
marked this pull request as ready for review
August 27, 2026 23:54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #307. Depends on posit-dev/chatlas#398 and tidyverse/ellmer#1106, which add the client-side
conversation_idsupport this PR consumes.Motivation
shinychat conversations have no identity that's visible outside the history store. This PR gives every conversation a stable ID and exposes it two ways:
chat$history$conversation_id()in R,chat.history.conversation_id()in Python — returning the active conversation's ID (NULL/Nonewhen history is disabled or the chat is still empty).gen_ai.conversation.idon its owninvoke_agent/chatOpenTelemetry spans — the standard attribute for this purpose in the OTel GenAI semantic conventions. shinychat itself emits no OTel spans.Downstream impact (Commons)
chatlas and ellmer already emit GenAI spans; they just had no conversation identity to correlate by. With the client recording the ID on its own spans, Commons can group all model work (token usage, latency, errors) by conversation using the standard attribute — no shinychat-specific knowledge required, and first responses that fail or are cancelled are still attributed. shinychat deliberately stays out of OTel: no tracer, no span lifecycle, no otel dependencies — just one assignment before the model call.
The Commons-side consumption is a follow-up in the Commons repo, not this PR.
Semantics
set_client()/chat.client.set(), and the savedConversationRecordadopts it.on_active_id_changefires at first submission rather than first save, so withrestore_mode = "url"the?shinychat_conversation_id=param appears while the first response streams (reloading such a URL falls through restore gracefully).Implementation
HistoryControllergains active-identity state separate from the saved record (a submitted-but-unsaved draft has an ID and no record), with one invariant: when a record exists, itsidequals the active ID. All restore/switch/init/delete/new-chat paths route through shared ops (activate_record()/clear_active()), and a single writer fires change notifications only on actual change.reactiveValtracks the live controller so the public reactive survivesset_client()'s history re-registration; the replacement controller inherits the ID only for unsaved drafts (seeding a saved ID would risk overwriting the stored record). The ID is assigned to the client'sconversation_idbinding before the model call (capability-checked until the ellmer release lands).conversation_idproperty before the model call; requireschatlas >= 0.22.0(temporarily resolved from Add settableconversation_idproperty toChatchatlas#398 viatool.uv.sourcesuntil release).Testing
test-conversation-id.R; Python: 20 tests intest_conversation_id.pymirroring the R matrix (lifecycle, integration, client-binding handoff).test-greeting.Rfailures also on main).ruff format --check, all pre-existing onmainand untouched here to avoid unrelated churn.