Skip to content

Public conversation ID, emitted on OTel spans as gen_ai.conversation.id - #343

Open
cpsievert wants to merge 16 commits into
mainfrom
feat/stable-conversation-id
Open

Public conversation ID, emitted on OTel spans as gen_ai.conversation.id#343
cpsievert wants to merge 16 commits into
mainfrom
feat/stable-conversation-id

Conversation

@cpsievert

@cpsievert cpsievert commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Closes #307. Depends on posit-dev/chatlas#398 and tidyverse/ellmer#1106, which add the client-side conversation_id support 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:

  1. Publicly, in app code: a new reactive accessor — chat$history$conversation_id() in R, chat.history.conversation_id() in Python — returning the active conversation's ID (NULL/None when history is disabled or the chat is still empty).
  2. In telemetry: the ID is handed to the chat client, which records it as gen_ai.conversation.id on its own invoke_agent/chat OpenTelemetry 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

  • The ID is allocated at the first user submission — before any model work — so it's available to app code and telemetry from the very first response.
  • It's stable across retries, restores, conversation switches, and set_client() / chat.client.set(), and the saved ConversationRecord adopts it.
  • The ID is set on the client from a scalar captured at submission time, so in-flight work is never relabeled by later switches or client swaps.
  • No ID is set when history is disabled. Greetings run before any ID exists. Conversation title generation clones the client after the ID is set, so title-generation spans also carry the ID — arguably correct, since they are model calls belonging to the conversation.
  • Minor behavior change: on_active_id_change fires at first submission rather than first save, so with restore_mode = "url" the ?shinychat_conversation_id= param appears while the first response streams (reloading such a URL falls through restore gracefully).

Implementation

HistoryController gains 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, its id equals 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.

  • R: a reactiveVal tracks the live controller so the public reactive survives set_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's conversation_id binding before the model call (capability-checked until the ellmer release lands).
  • Python: the ID is assigned to the client's conversation_id property before the model call; requires chatlas >= 0.22.0 (temporarily resolved from Add settable conversation_id property to Chat chatlas#398 via tool.uv.sources until release).

Testing

  • R: 25 tests in test-conversation-id.R; Python: 20 tests in test_conversation_id.py mirroring the R matrix (lifecycle, integration, client-binding handoff).
  • Full pytest suite (225 passed), ruff, and pyright pass; R suite passes (2 pre-existing test-greeting.R failures also on main).
  • Note: 6 Python files fail ruff format --check, all pre-existing on main and untouched here to avoid unrelated churn.

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
cpsievert force-pushed the feat/stable-conversation-id branch from ad286ba to de70f76 Compare August 25, 2026 00:51
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.
Comment thread pyproject.toml Outdated
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.
@cpsievert
cpsievert marked this pull request as ready for review August 27, 2026 22:55
@cpsievert
cpsievert requested a lite review from Copilot August 27, 2026 22:56

This comment was marked as resolved.

@cpsievert
cpsievert requested a review from gadenbuie August 27, 2026 23:32
@cpsievert
cpsievert marked this pull request as draft August 27, 2026 23:33
@cpsievert
cpsievert removed the request for review from gadenbuie August 27, 2026 23:34
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
cpsievert marked this pull request as ready for review August 27, 2026 23:54
@cpsievert
cpsievert requested a review from gadenbuie August 27, 2026 23:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: stable conversation identity for R chat_server()

2 participants