Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-langsmith for 
openSUSE:Factory checked in at 2026-07-18 22:24:48
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-langsmith (Old)
 and      /work/SRC/openSUSE:Factory/.python-langsmith.new.24530 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-langsmith"

Sat Jul 18 22:24:48 2026 rev:11 rq:1366477 version:0.10.6

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-langsmith/python-langsmith.changes        
2026-07-17 01:44:05.782508310 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-langsmith.new.24530/python-langsmith.changes 
    2026-07-18 22:26:02.152198621 +0200
@@ -1,0 +2,9 @@
+Sat Jul 18 05:55:29 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 0.10.6:
+  * Spread OpenAI request metadata into the traced metadata
+  * Capture Pipecat realtime tool calls, transcripts and OpenAI
+    realtime history
+  * Dependency and packaging fixes
+
+-------------------------------------------------------------------

Old:
----
  langsmith-0.10.5.tar.gz

New:
----
  langsmith-0.10.6.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-langsmith.spec ++++++
--- /var/tmp/diff_new_pack.HBUipe/_old  2026-07-18 22:26:02.740218480 +0200
+++ /var/tmp/diff_new_pack.HBUipe/_new  2026-07-18 22:26:02.740218480 +0200
@@ -17,7 +17,7 @@
 
 
 Name:           python-langsmith
-Version:        0.10.5
+Version:        0.10.6
 Release:        0
 Summary:        Client library for the LangSmith LLM tracing and evaluation 
platform
 License:        MIT

++++++ langsmith-0.10.5.tar.gz -> langsmith-0.10.6.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.5/.bumpversion.cfg 
new/langsmith-0.10.6/.bumpversion.cfg
--- old/langsmith-0.10.5/.bumpversion.cfg       2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.10.6/.bumpversion.cfg       2020-02-02 01:00:00.000000000 
+0100
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.10.5
+current_version = 0.10.6
 parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
 serialize = {major}.{minor}.{patch}
 search = {current_version}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.5/PKG-INFO 
new/langsmith-0.10.6/PKG-INFO
--- old/langsmith-0.10.5/PKG-INFO       2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/PKG-INFO       2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: langsmith
-Version: 0.10.5
+Version: 0.10.6
 Summary: Client library to connect to the LangSmith Observability and 
Evaluation Platform.
 Project-URL: Homepage, https://smith.langchain.com/
 Project-URL: Documentation, https://docs.smith.langchain.com/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.5/langsmith/__init__.py 
new/langsmith-0.10.6/langsmith/__init__.py
--- old/langsmith-0.10.5/langsmith/__init__.py  2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.10.6/langsmith/__init__.py  2020-02-02 01:00:00.000000000 
+0100
@@ -45,7 +45,7 @@
 
 # Avoid calling into importlib on every call to __version__
 
-__version__ = "0.10.5"
+__version__ = "0.10.6"
 version = __version__  # for backwards compatibility
 
 # Metadata key to hide a traced run from LangSmith's Messages View.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.5/langsmith/_internal/voice/translated_span.py 
new/langsmith-0.10.6/langsmith/_internal/voice/translated_span.py
--- old/langsmith-0.10.5/langsmith/_internal/voice/translated_span.py   
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/langsmith/_internal/voice/translated_span.py   
2020-02-02 01:00:00.000000000 +0100
@@ -10,6 +10,7 @@
 from __future__ import annotations
 
 import json
+from copy import copy
 from dataclasses import dataclass
 from typing import Any, Optional
 
@@ -29,11 +30,10 @@
 class TranslatedSpan:
     """A span being translated into LangSmith's namespaces before export.
 
-    Wraps the original read-only OTel ``ReadableSpan`` with mutable
-    ``attributes`` and ``events`` seeded from it. Handlers rewrite the copies
-    while translating; :meth:`finalize` builds a fresh ``ReadableSpan`` from 
them
-    — so OpenTelemetry's private ``span._attributes`` / ``span._events`` are
-    never mutated.
+    Wraps a shallow copy of the original read-only OTel ``ReadableSpan`` with
+    mutable ``attributes`` and ``events`` seeded from it. Handlers rewrite the
+    draft while translating; :meth:`finalize` builds a fresh ``ReadableSpan``
+    from it — so the original span is never mutated.
 
     Created per span in :meth:`BaseLangSmithSpanProcessor.on_end` and threaded
     through dispatch — no global state. A processor that defers a span (see
@@ -48,15 +48,31 @@
 
     @classmethod
     def of(cls, span: ReadableSpan) -> TranslatedSpan:
-        """Seed a draft from a span's own (read-only) attributes and events."""
-        return cls(span, dict(span.attributes or {}), list(span.events or []))
+        """Seed a draft from a span's own fields, attributes, and events."""
+        return cls(
+            copy(span),
+            dict(span.attributes or {}),
+            list(span.events or []),
+        )
 
     def finalize(self) -> ReadableSpan:
-        """Build the export span: the original's fields + our rewritten 
attrs/events."""
+        """Build the export span from the draft and its rewritten 
attrs/events."""
         return rebuild_readable_span(
             self.span, attributes=self.attributes, events=self.events
         )
 
+    def set_name(self, name: str) -> None:
+        """Set the exported span's name (the LangSmith run name)."""
+        self.span._name = name
+
+    def set_end_time(self, end_time_ns: int) -> None:
+        """Set the exported span's end time (epoch ns).
+
+        Lets a span merged from two sources report a duration that runs past 
its
+        own end — e.g. a tool span spanning from the call to the result.
+        """
+        self.span._end_time = end_time_ns
+
     def set_kind(self, kind: str) -> None:
         """Set ``langsmith.span.kind`` (``llm`` / ``chain`` / ``tool`` / …)."""
         self.attributes["langsmith.span.kind"] = kind
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.5/langsmith/integrations/pipecat/__init__.py 
new/langsmith-0.10.6/langsmith/integrations/pipecat/__init__.py
--- old/langsmith-0.10.5/langsmith/integrations/pipecat/__init__.py     
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/langsmith/integrations/pipecat/__init__.py     
2020-02-02 01:00:00.000000000 +0100
@@ -47,6 +47,11 @@
     and applies it to every span in the trace — so it holds even for spans
     finished on a background task, and concurrent conversations stay separated.
 
+    For a realtime (speech-to-speech) service, also call
+    :meth:`PipecatLangSmithSpanProcessor.instrument_user_aggregator` on the
+    returned processor. Pipecat delivers finalized user text through the user
+    context aggregator rather than an OTel span.
+
     Args:
         llm_span_kind: LangSmith run kind for Pipecat's ``llm`` span; see the
             :mod:`~langsmith.integrations.pipecat.processor` module docstring.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.5/langsmith/integrations/pipecat/_helpers.py 
new/langsmith-0.10.6/langsmith/integrations/pipecat/_helpers.py
--- old/langsmith-0.10.5/langsmith/integrations/pipecat/_helpers.py     
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/langsmith/integrations/pipecat/_helpers.py     
2020-02-02 01:00:00.000000000 +0100
@@ -3,7 +3,8 @@
 from __future__ import annotations
 
 import json
-from typing import Any
+from datetime import datetime
+from typing import Any, Optional
 
 from langsmith._internal.voice._helpers import (
     build_assistant_message,
@@ -61,3 +62,26 @@
         return {"role": "assistant", **parsed}
     content = output_data if isinstance(output_data, str) else str(output_data)
     return build_assistant_message(content)
+
+
+def iso_to_ns(timestamp: str) -> int:
+    """Parse an ISO 8601 timestamp to epoch nanoseconds (0 if unparseable)."""
+    try:
+        return int(datetime.fromisoformat(timestamp).timestamp() * 1e9)
+    except (ValueError, TypeError):
+        return 0
+
+
+def tool_message_key(message: dict[str, Any]) -> Optional[tuple]:
+    """Dedup identity for a tool-round-trip message, or ``None`` otherwise.
+
+    Only assistant tool-call messages and tool-result messages carry one;
+    user turns and plain assistant replies (owned by other sources) return
+    ``None`` so they are ignored here.
+    """
+    tool_calls = message.get("tool_calls")
+    if tool_calls:
+        return ("assistant_tool_call", tuple(str(c.get("id")) for c in 
tool_calls))
+    if message.get("role") == "tool":
+        return ("tool_result", str(message.get("tool_call_id")))
+    return None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.5/langsmith/integrations/pipecat/processor.py 
new/langsmith-0.10.6/langsmith/integrations/pipecat/processor.py
--- old/langsmith-0.10.5/langsmith/integrations/pipecat/processor.py    
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/langsmith/integrations/pipecat/processor.py    
2020-02-02 01:00:00.000000000 +0100
@@ -13,6 +13,7 @@
     └── turn × N
         ├── stt                  (audio → transcript)
         ├── llm                  (the LLM stage; kind set by llm_span_kind)
+        ├── tool × N             (realtime: one per tool call, args → result)
         └── tts                  (response text → audio)
 
 Audio uses Pipecat's ``AudioBufferProcessor``: wire it with
@@ -26,6 +27,11 @@
 
 Speech-to-speech (realtime) services emit operation-named spans: 
``llm_response``
 (→ ``llm`` run, usage + reply) and the ``llm_setup`` / ``llm_request`` 
wrappers.
+Call :meth:`PipecatLangSmithSpanProcessor.instrument_user_aggregator` to supply
+finalized user transcripts emitted outside those spans. A realtime tool call
+arrives as two sibling spans, ``llm_tool_call`` (args) then
+``llm_tool_result`` (output); the processor defers the call and merges the 
result
+onto it, so each call renders as one ``tool`` run spanning call → result.
 """
 
 from __future__ import annotations
@@ -51,10 +57,24 @@
 from langsmith.integrations.pipecat._helpers import (
     build_completion_message,
     extract_llm_usage,
+    iso_to_ns,
     parse_llm_messages,
+    tool_message_key,
 )
 
+# Sort key for transcript entries: (epoch nanoseconds, sequence). Ordering the
+# rollup by when each message actually occurred — rather than by the order 
spans
+# arrive — keeps realtime user turns (whose transcripts land late, after the
+# model has already replied) in their spoken position. ``seq`` breaks ties 
within
+# one source (e.g. tool call before its result from the same snapshot).
+_SortKey = tuple[int, int]
+
 if TYPE_CHECKING:
+    from pipecat.processors.aggregators.llm_response_universal import (  # 
type: ignore[import-not-found]
+        LLMContextAggregatorPair,
+        LLMUserAggregator,
+        UserTurnMessageAddedMessage,
+    )
     from pipecat.processors.audio.audio_buffer_processor import (  # type: 
ignore[import-not-found]
         AudioBufferProcessor,
     )
@@ -135,15 +155,97 @@
         )
         self._llm_span_kind = llm_span_kind
         self._audio_mime_type = audio_mime_type
-        # trace_id -> latest llm context (the full history == the 
conversation),
-        # rendered onto the root ``conversation`` span, which ends last.
-        self._transcript_by_trace: MutableMapping[int, list[dict[str, Any]]] = 
TTLCache(
-            maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds
-        )
+        # trace_id -> the conversation the root renders, as ``(sort_key, 
message)``
+        # entries ordered by when each message occurred (see ``_SortKey``).
+        self._transcript_by_trace: MutableMapping[
+            int, list[tuple[_SortKey, dict[str, Any]]]
+        ] = TTLCache(maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds)
         # conversation id -> merged PCM from an AudioBufferProcessor.
         self._audio_by_conversation: MutableMapping[str, _AudioRecord] = 
TTLCache(
             maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds
         )
+        # trace_id -> FIFO of deferred ``llm_tool_call`` spans, each held until
+        # its ``llm_tool_result`` arrives so the two merge into one tool span.
+        self._tool_calls_by_trace: MutableMapping[int, list[TranslatedSpan]] = 
TTLCache(
+            maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds
+        )
+        # thread id -> trace_id, so realtime user transcripts delivered outside
+        # spans can find the conversation rollup they belong to.
+        self._trace_by_thread: MutableMapping[str, int] = TTLCache(
+            maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds
+        )
+        # thread id -> user turns (as ``(sort_key, message)``) that arrived 
before
+        # their trace was known.
+        self._pending_user_transcripts: MutableMapping[
+            str, list[tuple[_SortKey, dict[str, Any]]]
+        ] = TTLCache(maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds)
+
+    def _remember_thread_id(self, trace_id: int, thread_id: str) -> None:
+        """Also index thread→trace, draining user turns buffered before the 
map."""
+        super()._remember_thread_id(trace_id, thread_id)
+        self._trace_by_thread[thread_id] = trace_id
+        pending = self._pending_user_transcripts.pop(thread_id, None)
+        for sort_key, message in pending or []:
+            self._append_transcript(trace_id, message, sort_key)
+
+    # -- realtime user transcript --------------------------------------------
+
+    def instrument_user_aggregator(
+        self, aggregator: LLMContextAggregatorPair, thread_id: str
+    ) -> None:
+        """Fold a realtime session's finalized user transcripts into its trace.
+
+        Pipecat realtime services emit the user's finalized text through the 
user
+        context aggregator's ``on_user_turn_message_added`` event rather than 
on
+        an OTel span. Call this once after building the context aggregators::
+
+            processor = configure_pipecat(...)
+            aggregators = LLMContextAggregatorPair(context)
+            set_thread_id(conversation_id)
+            processor.instrument_user_aggregator(aggregators, conversation_id)
+
+        The aggregator is the authoritative source of user turns: a realtime
+        service transcribes the user's audio asynchronously, so the text lands
+        (via this event, carrying the turn-start timestamp) after the model has
+        already replied. The transcript is ordered by that timestamp, not 
arrival.
+
+        Args:
+            aggregator: the ``LLMContextAggregatorPair`` for the conversation.
+            thread_id: the conversation id, matching :func:`set_thread_id`.
+        """
+        user_aggregator = aggregator.user()
+        tid = str(thread_id)
+
+        @user_aggregator.event_handler("on_user_turn_message_added")
+        async def _on_user_turn_message_added(
+            _aggregator: LLMUserAggregator, message: 
UserTurnMessageAddedMessage
+        ) -> None:
+            self._record_user_transcript(tid, message.content, 
message.timestamp)
+
+    def _record_user_transcript(
+        self, thread_id: str, text: str, timestamp: str
+    ) -> None:
+        """Append or buffer a finalized user turn, keyed by when it was 
spoken."""
+        if not text:
+            return
+        message = build_user_message(text)
+        sort_key = (iso_to_ns(timestamp), 0)
+        trace_id = self._trace_by_thread.get(thread_id)
+        if trace_id is None:
+            pending = self._pending_user_transcripts.get(thread_id) or []
+            pending.append((sort_key, message))
+            self._pending_user_transcripts[thread_id] = pending
+            return
+        self._append_transcript(trace_id, message, sort_key)
+
+    def _append_transcript(
+        self, trace_id: int, message: dict[str, Any], sort_key: _SortKey
+    ) -> None:
+        """Add a message to the transcript the root renders, keyed for 
ordering."""
+        conversation = self._transcript_by_trace.get(trace_id) or []
+        conversation.append((sort_key, message))
+        # Re-assign (not just mutate) so each turn refreshes the cache TTL.
+        self._transcript_by_trace[trace_id] = conversation
 
     # -- audio buffer registration -------------------------------------------
 
@@ -215,8 +317,14 @@
             self._handle_conversation(tspan, trace_id)
         elif name == "llm_response":  # realtime: usage + reply
             self._handle_realtime_response(tspan, trace_id)
-        elif name in ("llm_setup", "llm_request"):
-            tspan.set_kind("chain")  # realtime wrappers
+        elif name == "llm_request":  # OpenAI realtime: history snapshot
+            self._handle_llm_request(tspan, trace_id)
+        elif name == "llm_setup":
+            tspan.set_kind("chain")  # realtime wrapper
+        elif name == "llm_tool_call":  # realtime: defer, merge with its result
+            return self._handle_tool_call(tspan, trace_id)
+        elif name == "llm_tool_result":
+            return self._handle_tool_result(tspan, trace_id)
         return True
 
     # -- per-span-type handlers ----------------------------------------------
@@ -224,15 +332,20 @@
     def _handle_stt(self, tspan: TranslatedSpan, trace_id: int) -> None:
         """STT span: audio input → transcribed text."""
         transcript = tspan.attributes.get("transcript", "")
+        is_final = tspan.attributes.get("is_final", True)
         tspan.set_kind("llm")
         tspan.set_messages(prompt=[build_user_message(f'Audio for: 
"{transcript}"')])
         if transcript:
             
tspan.set_messages(completion=[build_assistant_message(str(transcript))])
-            # Fold the user turn into the rollup (realtime has no cascade llm
-            # span to carry it; cascade's llm span replaces it with full 
history).
-            if tspan.attributes.get("is_final", True):
-                self._transcript_by_trace.setdefault(trace_id, []).append(
-                    build_user_message(str(transcript))
+            # Fold the finalized user turn into the rollup, keyed by when it 
was
+            # transcribed. Realtime services emit only interim (is_final=False)
+            # stt spans, so their user turns come from the aggregator instead;
+            # this fold is the cascade path (STT→LLM→TTS, no aggregator).
+            if is_final:
+                self._append_transcript(
+                    trace_id,
+                    build_user_message(str(transcript)),
+                    (tspan.span.start_time or 0, 0),
                 )
         tspan.exclude_from_message_view()
 
@@ -256,11 +369,17 @@
         if usage:
             tspan.set_usage(**usage)
 
+        # Cascade: the ``llm`` input already carries the whole ordered 
history, so
+        # replace the transcript with this snapshot, keyed so the messages keep
+        # their order and sort after earlier turns.
         transcript = [m for m in messages if m.get("role") != "system"]
         if output_data:
             transcript.append(build_completion_message(output_data))
         if transcript:
-            self._transcript_by_trace[trace_id] = transcript
+            base = tspan.span.start_time or 0
+            self._transcript_by_trace[trace_id] = [
+                ((base, i), message) for i, message in enumerate(transcript)
+            ]
 
     def _handle_tts(self, tspan: TranslatedSpan) -> None:
         """TTS span: text → audio. The voice is metadata, not content."""
@@ -276,20 +395,105 @@
         tspan.exclude_from_message_view()
 
     def _handle_realtime_response(self, tspan: TranslatedSpan, trace_id: int) 
-> None:
-        """``llm_response`` span from a realtime (speech-to-speech) service."""
+        """``llm_response`` span: conversation so far → realtime reply."""
         tspan.set_kind(self._llm_span_kind)
+        conversation = self._render_messages(trace_id)
+        if conversation:
+            tspan.set_messages(prompt=conversation)
+
         output_data = tspan.attributes.get("output") or tspan.attributes.get(
             "text_output", ""
         )
         if output_data:
             completion = build_completion_message(output_data)
             tspan.set_messages(completion=[completion])
-            self._transcript_by_trace.setdefault(trace_id, 
[]).append(completion)
+            self._append_transcript(
+                trace_id, completion, (tspan.span.start_time or 0, 0)
+            )
 
         usage = extract_llm_usage(tspan.attributes)
         if usage:
             tspan.set_usage(**usage)
 
+    def _handle_llm_request(self, tspan: TranslatedSpan, trace_id: int) -> 
None:
+        """``llm_request`` span (OpenAI realtime): capture the tool round-trip.
+
+        For OpenAI realtime, tool calls and results have no spans of their own 
—
+        they exist only in this context snapshot. User turns come from the
+        aggregator and assistant replies from ``llm_response``, so take *only* 
the
+        tool-call / tool-result messages here, deduped by their id, keyed by 
this
+        span's time so they sort after the user turn that triggered them.
+        """
+        tspan.set_kind("chain")
+        messages = parse_llm_messages(tspan.attributes.get("input", ""))
+        seen = {
+            key
+            for message in self._render_messages(trace_id)
+            if (key := tool_message_key(message)) is not None
+        }
+        base = tspan.span.start_time or 0
+        added = 0
+        for message in messages:
+            key = tool_message_key(message)
+            if key is None or key in seen:
+                continue
+            seen.add(key)
+            self._append_transcript(trace_id, message, (base, added))
+            added += 1
+
+    def _render_messages(self, trace_id: int) -> list[dict[str, Any]]:
+        """Return the transcript as plain messages, ordered by sort key."""
+        entries = self._transcript_by_trace.get(trace_id, [])
+        return [message for _, message in sorted(entries, key=lambda e: e[0])]
+
+    def _handle_tool_call(self, tspan: TranslatedSpan, trace_id: int) -> bool:
+        """``llm_tool_call`` span: defer the call (args) until its result 
arrives."""
+        tspan.set_kind("tool")
+        function_name = tspan.attributes.get("tool.function_name")
+        if function_name:
+            tspan.set_name(str(function_name))
+        arguments = tspan.attributes.get("tool.arguments")
+        if arguments is not None:
+            tspan.set_tool_input(arguments)
+        self._tool_calls_by_trace.setdefault(trace_id, []).append(tspan)
+        return False  # deferred; exported when its result arrives
+
+    def _handle_tool_result(self, tspan: TranslatedSpan, trace_id: int) -> 
bool:
+        """``llm_tool_result`` span: merge the result onto its deferred call 
span.
+
+        Pairs with the oldest deferred call for the trace (Gemini Live puts no
+        usable id on the result span, so pairing is by order). The result 
becomes
+        the tool run's output and stretches the span to end at the result, so 
it
+        spans call → result.
+        """
+        result = tspan.attributes.get("tool.result")
+        call_tspan = self._take_pending_call(trace_id)
+        if call_tspan is None:
+            tspan.set_kind("tool")
+            if result is not None:
+                tspan.set_tool_output(result)
+            return True
+
+        if result is not None:
+            call_tspan.set_tool_output(result)
+        status = tspan.attributes.get("tool.result_status")
+        if status is not None:
+            call_tspan.set_metadata("tool_result_status", str(status))
+        if tspan.span.end_time is not None:
+            call_tspan.set_end_time(tspan.span.end_time)
+        self._export(call_tspan)
+        return False  # merged into the call span; drop this result span
+
+    def _take_pending_call(self, trace_id: int) -> Optional[TranslatedSpan]:
+        """Pop the oldest deferred call span for the trace, or ``None``."""
+        queue = self._tool_calls_by_trace.get(trace_id)
+        if not queue:
+            return None
+        call_tspan = queue.pop(0)
+        if not queue:
+            self._tool_calls_by_trace.pop(trace_id, None)
+        return call_tspan
+
     def _handle_turn(self, tspan: TranslatedSpan) -> None:
         """Turn span: a framework wrapper around one exchange (a ``chain``)."""
         tspan.set_kind("chain")
@@ -313,7 +517,7 @@
             "ls_integration_version", get_package_version("pipecat-ai") or ""
         )
 
-        messages = self._transcript_by_trace.get(trace_id, [])
+        messages = self._render_messages(trace_id)
         if messages:
             tspan.set_messages(prompt=messages)
 
@@ -359,6 +563,28 @@
         )
 
     def _cleanup_conversation(self, trace_id: int, conversation_id: str) -> 
None:
+        thread_id = self._thread_id_by_trace.get(trace_id)
         self._transcript_by_trace.pop(trace_id, None)
         self._audio_by_conversation.pop(conversation_id, None)
+        if thread_id is not None:
+            self._trace_by_thread.pop(thread_id, None)
+            self._pending_user_transcripts.pop(thread_id, None)
         self._forget_thread_id(trace_id)
+        self._flush_tool_calls(trace_id)
+
+    def _flush_tool_calls(self, trace_id: Optional[int] = None) -> None:
+        """Export held tool-call spans whose result never arrived (args only).
+
+        Scoped to one trace at conversation end, or all held spans at shutdown.
+        """
+        trace_ids = (
+            [trace_id] if trace_id is not None else 
list(self._tool_calls_by_trace)
+        )
+        for tid in trace_ids:
+            for tspan in self._tool_calls_by_trace.pop(tid, None) or []:
+                self._export(tspan)
+
+    def shutdown(self) -> None:
+        """Flush any still-held tool-call spans, then shut down downstream."""
+        self._flush_tool_calls()
+        super().shutdown()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.5/langsmith/run_trees.py 
new/langsmith-0.10.6/langsmith/run_trees.py
--- old/langsmith-0.10.5/langsmith/run_trees.py 2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.10.6/langsmith/run_trees.py 2020-02-02 01:00:00.000000000 
+0100
@@ -1187,6 +1187,7 @@
 
                 api_url = item.get("api_url")
                 api_key = item.get("api_key")
+                project_name = item.get("project_name")
 
                 if not isinstance(api_url, str):
                     logger.warning(
@@ -1202,11 +1203,18 @@
                     )
                     continue
 
+                if project_name is not None and not isinstance(project_name, 
str):
+                    logger.warning(
+                        f"Invalid project_name type in 
LANGSMITH_RUNS_ENDPOINTS: "
+                        f"expected string, got {type(project_name).__name__}"
+                    )
+                    continue
+
                 replicas.append(
                     WriteReplica(
                         api_url=api_url.rstrip("/"),
                         auth=AuthHeaders(api_key=api_key),
-                        project_name=None,
+                        project_name=project_name,
                         updates=None,
                     )
                 )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.5/langsmith/wrappers/_openai.py 
new/langsmith-0.10.6/langsmith/wrappers/_openai.py
--- old/langsmith-0.10.5/langsmith/wrappers/_openai.py  2020-02-02 
01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/langsmith/wrappers/_openai.py  2020-02-02 
01:00:00.000000000 +0100
@@ -133,7 +133,12 @@
     if use_responses_api:
         invocation_params["use_responses_api"] = True
 
+    request_metadata = stripped.get("metadata")
+    if not isinstance(request_metadata, Mapping):
+        request_metadata = {}
+
     return {
+        **request_metadata,
         "ls_provider": provider,
         "ls_model_type": model_type,
         "ls_model_name": stripped.get("model"),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.5/pyproject.toml 
new/langsmith-0.10.6/pyproject.toml
--- old/langsmith-0.10.5/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
@@ -191,6 +191,13 @@
 
 [tool.uv]
 exclude-newer = "P1D"
+# Force `json-repair` past the patched 0.60.1 floor everywhere. 
`livekit-agents`
+# pins the vulnerable `json-repair==0.59.10`; uv's `override-dependencies` 
takes a
+# flat list of PEP 508 requirements (not a per-package map), so a global 
override
+# is the supported way to force the patched version over that pin.
+override-dependencies = [
+    "json-repair>=0.60.1",
+]
 
 [tool.hatch.version]
 path = "langsmith/__init__.py"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.5/tests/unit_tests/test_replica_endpoints.py 
new/langsmith-0.10.6/tests/unit_tests/test_replica_endpoints.py
--- old/langsmith-0.10.5/tests/unit_tests/test_replica_endpoints.py     
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/tests/unit_tests/test_replica_endpoints.py     
2020-02-02 01:00:00.000000000 +0100
@@ -353,8 +353,16 @@
         """Test parsing new array format."""
         env_var = json.dumps(
             [
-                {"api_url": "https://api.example.com";, "api_key": "key1"},
-                {"api_url": "https://api.example.com";, "api_key": "key2"},
+                {
+                    "api_url": "https://api.example.com";,
+                    "api_key": "key1",
+                    "project_name": "project-prod",
+                },
+                {
+                    "api_url": "https://api.example.com";,
+                    "api_key": "key2",
+                    "project_name": "project-staging",
+                },
                 {"api_url": "https://api.example.com";, "api_key": "key3"},
             ]
         )
@@ -372,8 +380,11 @@
         assert "key2" in keys
         assert "key3" in keys
 
-        # All should have None for project_name and updates
-        assert all(r["project_name"] is None for r in result)
+        assert [r["project_name"] for r in result] == [
+            "project-prod",
+            "project-staging",
+            None,
+        ]
         assert all(r["updates"] is None for r in result)
 
     def test_parse_object_format(self):
@@ -815,6 +826,38 @@
         assert call_args[1]["api_key"] == "replica-key"
         assert call_args[1]["api_url"] == "https://replica.example.com";
 
+    def test_run_tree_preserves_env_replica_project_names(self):
+        client = Mock()
+        env_var = json.dumps(
+            [
+                {
+                    "api_url": "https://replica1.example.com";,
+                    "api_key": "replica1-key",
+                    "project_name": "project-prod",
+                },
+                {
+                    "api_url": "https://replica2.example.com";,
+                    "api_key": "replica2-key",
+                    "project_name": "project-staging",
+                },
+            ]
+        )
+
+        with patch.dict(os.environ, {"LANGSMITH_RUNS_ENDPOINTS": env_var}, 
clear=True):
+            _parse_write_replicas_from_env_var.cache_clear()
+            run_tree = RunTree(
+                name="test_run",
+                inputs={"input": "test"},
+                client=client,
+                project_name="fallback-project",
+            )
+            run_tree.post()
+
+        session_names = [
+            call.kwargs["session_name"] for call in 
client.create_run.call_args_list
+        ]
+        assert session_names == ["project-prod", "project-staging"]
+
 
 class TestBaggageReplicaParsing:
     """Test baggage header parsing for replicas."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.5/tests/unit_tests/wrappers/test_openai_processing.py 
new/langsmith-0.10.6/tests/unit_tests/wrappers/test_openai_processing.py
--- old/langsmith-0.10.5/tests/unit_tests/wrappers/test_openai_processing.py    
1970-01-01 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/tests/unit_tests/wrappers/test_openai_processing.py    
2020-02-02 01:00:00.000000000 +0100
@@ -0,0 +1,58 @@
+"""Unit tests for OpenAI wrapper processing functions."""
+
+import pytest
+
+from langsmith.wrappers._openai import _infer_invocation_params
+
+
+def test_infer_invocation_params_copies_request_metadata():
+    result = _infer_invocation_params(
+        "chat",
+        "openai",
+        {},
+        False,
+        {
+            "model": "gpt-4o-mini",
+            "metadata": {
+                "customer_id": "customer-123",
+                "environment": "test",
+            },
+        },
+    )
+
+    assert result["customer_id"] == "customer-123"
+    assert result["environment"] == "test"
+    assert "metadata" not in result["ls_invocation_params"]
+
+
+def test_infer_invocation_params_protects_langsmith_metadata():
+    result = _infer_invocation_params(
+        "chat",
+        "openai",
+        {},
+        False,
+        {
+            "model": "gpt-4o-mini",
+            "metadata": {
+                "ls_provider": "other",
+                "ls_model_name": "other-model",
+            },
+        },
+    )
+
+    assert result["ls_provider"] == "openai"
+    assert result["ls_model_name"] == "gpt-4o-mini"
+
+
[email protected]("metadata", [None, "invalid", ["invalid"]])
+def test_infer_invocation_params_ignores_non_mapping_metadata(metadata):
+    result = _infer_invocation_params(
+        "chat",
+        "openai",
+        {},
+        False,
+        {"model": "gpt-4o-mini", "metadata": metadata},
+    )
+
+    assert result["ls_provider"] == "openai"
+    assert result["ls_model_name"] == "gpt-4o-mini"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.5/tests/unit_tests/wrappers/test_pipecat.py 
new/langsmith-0.10.6/tests/unit_tests/wrappers/test_pipecat.py
--- old/langsmith-0.10.5/tests/unit_tests/wrappers/test_pipecat.py      
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/tests/unit_tests/wrappers/test_pipecat.py      
2020-02-02 01:00:00.000000000 +0100
@@ -7,11 +7,14 @@
 required (only ``opentelemetry-sdk``, a dev dependency).
 """
 
+import asyncio
 import base64
 import io
 import json
 import sys
 import wave
+from datetime import datetime
+from types import SimpleNamespace
 from unittest.mock import MagicMock, patch
 
 import pytest
@@ -30,22 +33,38 @@
 )
 
 
-def _make_span(name: str, attributes: dict | None = None, trace_id: int = 0x1):
+def _ns(iso: str) -> int:
+    """Epoch nanoseconds for an ISO 8601 timestamp (the transcript sort 
scale)."""
+    return int(datetime.fromisoformat(iso).timestamp() * 1e9)
+
+
+def _make_span(
+    name: str,
+    attributes: dict | None = None,
+    trace_id: int = 0x1,
+    start_time: int = 0,
+):
     """Build a mock span for the processor.
 
-    The processor only *reads* ``span.attributes`` (and a few read-only 
fields);
-    it never mutates the span. Translation accumulates on a ``TranslatedSpan``
-    draft, and a fresh span is built for export — so the rewritten attributes 
are
-    read off the exported span (see :func:`_exported_attrs`), not this input. 
The
-    remaining ``ReadableSpan`` fields are auto-mocked, which is all
-    ``TranslatedSpan.finalize`` needs to construct the export span.
+    The processor copies the span into a ``TranslatedSpan`` draft and builds a
+    fresh span for export, leaving this input unchanged. The remaining
+    ``ReadableSpan`` fields are auto-mocked, which is all ``finalize`` needs.
+    ``start_time`` (epoch ns) is the transcript sort key for span-sourced
+    messages; pass it (e.g. via :func:`_ns`) when a test asserts ordering.
     """
     span = MagicMock()
     span.name = name
+    span._name = name
     span.attributes = dict(attributes or {})
     span.context = MagicMock()
     span.context.trace_id = trace_id
     span.events = []
+    span.end_time = None
+    span._end_time = None
+    span._start_time = start_time
+    type(span).name = property(lambda value: value._name)
+    type(span).end_time = property(lambda value: value._end_time)
+    type(span).start_time = property(lambda value: value._start_time)
     return span
 
 
@@ -241,6 +260,244 @@
         proc.downstream.on_end.assert_called_once()
 
 
+class TestPipecatRealtimeHistory:
+    """OpenAI realtime ``llm_request`` contributes only the tool round-trip."""
+
+    def test_llm_request_contributes_tool_structure_only(self):
+        # OpenAI realtime emits no per-tool spans, so the assistant tool call 
and
+        # its result are taken from the llm_request context snapshot. The user 
and
+        # plain-assistant messages in that snapshot are ignored (they come from
+        # the aggregator and llm_response); the spoken reply arrives on
+        # llm_response. Ordering is by span time: request (t=1) then response 
(t=2).
+        proc = _processor()
+        trace_id = 0xDEF
+        history = [
+            {"role": "system", "content": "be nice"},
+            {"role": "user", "content": "weather?"},
+            {
+                "role": "assistant",
+                "content": None,
+                "tool_calls": [
+                    {
+                        "id": "c1",
+                        "type": "function",
+                        "function": {
+                            "name": "get_weather",
+                            "arguments": '{"city":"SF"}',
+                        },
+                    }
+                ],
+            },
+            {"role": "tool", "tool_call_id": "c1", "content": '{"temp": 68}'},
+        ]
+        req = _make_span(
+            "llm_request",
+            {"input": json.dumps(history)},
+            trace_id=trace_id,
+            start_time=_ns("2026-01-01T00:00:01+00:00"),
+        )
+        proc.on_end(req)
+        # The llm_request span itself stays a chain wrapper (usage is on 
response).
+        assert _exported_attrs(proc)["langsmith.span.kind"] == "chain"
+
+        resp = _make_span(
+            "llm_response",
+            {"output": "it's 68"},
+            trace_id=trace_id,
+            start_time=_ns("2026-01-01T00:00:02+00:00"),
+        )
+        proc.on_end(resp)
+
+        convo = _make_span("conversation", {"conversation.id": "c"}, 
trace_id=trace_id)
+        proc.on_end(convo)
+
+        prompt = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+        # No user turn (that needs the aggregator); tool call + result + reply.
+        assert [m["role"] for m in prompt] == ["assistant", "tool", 
"assistant"]
+        assert prompt[0]["tool_calls"][0]["function"]["name"] == "get_weather"
+        assert prompt[1]["content"] == '{"temp": 68}'  # tool result rendered
+        assert prompt[2]["content"] == "it's 68"  # spoken reply appended
+
+    def test_llm_request_dedupes_tool_round_trip_across_snapshots(self):
+        # Each snapshot is the full history, so the same tool call recurs. 
Dedup by
+        # tool_call_id keeps one assistant-call + one result, not one per 
snapshot.
+        proc = _processor()
+        tid = 0x1
+        first = [
+            {
+                "role": "assistant",
+                "content": None,
+                "tool_calls": [
+                    {
+                        "id": "c1",
+                        "type": "function",
+                        "function": {"name": "weather", "arguments": "{}"},
+                    }
+                ],
+            },
+            {"role": "tool", "tool_call_id": "c1", "content": "sunny"},
+        ]
+        proc.on_end(
+            _make_span(
+                "llm_request",
+                {"input": json.dumps(first)},
+                tid,
+                _ns("2026-01-01T00:00:01+00:00"),
+            )
+        )
+        proc.on_end(
+            _make_span(
+                "llm_request",
+                {"input": json.dumps(first)},
+                tid,
+                _ns("2026-01-01T00:00:02+00:00"),
+            )
+        )
+        proc.on_end(_make_span("conversation", {"conversation.id": "c"}, 
trace_id=tid))
+
+        prompt = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+        assert [m["role"] for m in prompt] == ["assistant", "tool"]
+
+    def test_cascade_llm_handling_unchanged(self):
+        # Cascade emits `llm`, never `llm_request`; its snapshot+append 
behavior
+        # is untouched by the realtime path.
+        proc = _processor()
+        tid = 0x1
+        proc.on_end(
+            _make_span(
+                "llm",
+                {
+                    "input": json.dumps([{"role": "user", "content": "q"}]),
+                    "output": "a",
+                },
+                trace_id=tid,
+            )
+        )
+        proc.on_end(_make_span("conversation", {"conversation.id": "c"}, 
trace_id=tid))
+
+        prompt = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+        assert [m["content"] for m in prompt] == ["q", "a"]
+
+
+class TestPipecatToolCalls:
+    """Realtime tool spans: ``llm_tool_call`` + ``llm_tool_result`` merge to 
one run."""
+
+    @staticmethod
+    def _exported_spans(proc) -> list:
+        """Every finalized span forwarded downstream, in order."""
+        return [c.args[0] for c in proc.downstream.on_end.call_args_list]
+
+    def test_call_and_result_merge_into_one_span(self):
+        # Pipecat puts no usable id on the result span, so pairing is by order.
+        proc = _processor()
+        trace_id = 0xABC
+        call = _make_span(
+            "llm_tool_call",
+            {"tool.function_name": "get_weather", "tool.arguments": '{"city": 
"SF"}'},
+            trace_id=trace_id,
+        )
+
+        # The call span is held (deferred) until its result arrives.
+        proc.on_end(call)
+        assert not proc.downstream.on_end.called
+
+        result = _make_span(
+            "llm_tool_result",
+            {"tool.result": '{"temp": 68}', "tool.result_status": "completed"},
+            trace_id=trace_id,
+        )
+        result._end_time = 999
+
+        proc.on_end(result)
+
+        # One span exported: the merged call span; the result span is dropped.
+        exported = self._exported_spans(proc)
+        assert len(exported) == 1
+        span = exported[0]
+        attrs = dict(span.attributes)
+        assert attrs["langsmith.span.kind"] == "tool"
+        assert span.name == "get_weather"
+        assert attrs["gen_ai.prompt"] == '{"city": "SF"}'
+        assert attrs["gen_ai.completion"] == '{"temp": 68}'
+        assert attrs["langsmith.metadata.tool_result_status"] == "completed"
+        assert span.end_time == 999  # stretched to the result's end
+        assert not proc._tool_calls_by_trace
+
+    def test_result_without_call_exported_standalone(self):
+        # A result with no deferred call still surfaces as a tool run (output 
only).
+        proc = _processor()
+        result = _make_span("llm_tool_result", {"tool.result": '{"ok": true}'})
+
+        proc.on_end(result)
+
+        attrs = _exported_attrs(proc)
+        assert attrs["langsmith.span.kind"] == "tool"
+        assert attrs["gen_ai.completion"] == '{"ok": true}'
+        assert "gen_ai.prompt" not in attrs  # no args were ever seen
+
+    def test_multiple_calls_pair_in_order(self):
+        # Two sequential calls, two results: each result pairs with the oldest
+        # deferred call (FIFO).
+        proc = _processor()
+        trace_id = 0xABC
+        for name, args in (("a", '{"x": 1}'), ("b", '{"y": 2}')):
+            proc.on_end(
+                _make_span(
+                    "llm_tool_call",
+                    {"tool.function_name": name, "tool.arguments": args},
+                    trace_id=trace_id,
+                )
+            )
+        for res in ('"A"', '"B"'):
+            proc.on_end(
+                _make_span("llm_tool_result", {"tool.result": res}, 
trace_id=trace_id)
+            )
+
+        exported = self._exported_spans(proc)
+        by_name = {s.name: dict(s.attributes) for s in exported}
+        assert by_name["a"]["gen_ai.prompt"] == '{"x": 1}'
+        assert by_name["a"]["gen_ai.completion"] == '"A"'
+        assert by_name["b"]["gen_ai.prompt"] == '{"y": 2}'
+        assert by_name["b"]["gen_ai.completion"] == '"B"'
+        assert not proc._tool_calls_by_trace
+
+    def test_orphaned_call_flushed_on_conversation_end(self):
+        # A call whose result never arrives is flushed (args only) when the
+        # conversation ends, not held indefinitely.
+        proc = _processor()
+        trace_id = 0xABC
+        call = _make_span(
+            "llm_tool_call",
+            {"tool.function_name": "hang", "tool.arguments": "{}"},
+            trace_id=trace_id,
+        )
+        proc.on_end(call)
+        assert not proc.downstream.on_end.called
+
+        convo = _make_span("conversation", {"conversation.id": "x"}, 
trace_id=trace_id)
+        proc.on_end(convo)
+
+        exported = self._exported_spans(proc)
+        kinds = [dict(s.attributes).get("langsmith.span.kind") for s in 
exported]
+        assert "tool" in kinds  # the orphaned call was flushed
+        assert not proc._tool_calls_by_trace
+
+    def test_orphaned_call_flushed_on_shutdown(self):
+        proc = _processor()
+        call = _make_span(
+            "llm_tool_call", {"tool.function_name": "hang", "tool.arguments": 
"{}"}
+        )
+        proc.on_end(call)
+        assert not proc.downstream.on_end.called
+
+        proc.shutdown()
+
+        attrs = _exported_attrs(proc)
+        assert attrs["langsmith.span.kind"] == "tool"
+        assert not proc._tool_calls_by_trace
+        proc.downstream.shutdown.assert_called_once()
+
+
 class TestPipecatAudio:
     """Audio-buffer registration, accumulation, and root attachment."""
 
@@ -636,6 +893,301 @@
             assert configure_pipecat() is None
 
 
+class _FakeUserAggregator:
+    """Minimal Pipecat user aggregator event emitter."""
+
+    def __init__(self):
+        self.handlers = {}
+
+    def event_handler(self, event_name):
+        def decorator(handler):
+            self.handlers[event_name] = handler
+            return handler
+
+        return decorator
+
+    def emit(self, content, timestamp="2026-01-01T00:00:00+00:00"):
+        message = SimpleNamespace(content=content, timestamp=timestamp)
+        asyncio.run(self.handlers["on_user_turn_message_added"](self, message))
+
+
+class _FakeAggregatorPair:
+    """Minimal Pipecat context aggregator pair."""
+
+    def __init__(self):
+        self.user_aggregator = _FakeUserAggregator()
+
+    def user(self):
+        return self.user_aggregator
+
+
+class TestPipecatRealtimeTranscript:
+    """Realtime user events populate response inputs and the root 
transcript."""
+
+    def test_aggregator_pair_records_user_turn(self):
+        proc = _processor()
+        trace_id = 0x71
+        proc._remember_thread_id(trace_id, "conv-1")
+        aggregators = _FakeAggregatorPair()
+        proc.instrument_user_aggregator(aggregators, "conv-1")
+
+        aggregators.user().emit("what's the weather?")
+
+        assert proc._render_messages(trace_id) == [
+            {"role": "user", "content": "what's the weather?"}
+        ]
+
+    def test_invalid_aggregator_raises(self):
+        proc = _processor()
+        with pytest.raises(AttributeError):
+            proc.instrument_user_aggregator(object(), "conv-2")
+
+    def test_repeated_finalized_events_are_distinct_turns(self):
+        # Identical utterances are kept as separate turns (no dedup).
+        proc = _processor()
+        trace_id = 0x72
+        proc._remember_thread_id(trace_id, "conv-2")
+        aggregators = _FakeAggregatorPair()
+        proc.instrument_user_aggregator(aggregators, "conv-2")
+
+        aggregators.user().emit("yes", "2026-01-01T00:00:01+00:00")
+        aggregators.user().emit("yes", "2026-01-01T00:00:02+00:00")
+
+        assert proc._render_messages(trace_id) == [
+            {"role": "user", "content": "yes"},
+            {"role": "user", "content": "yes"},
+        ]
+
+    def test_transcript_before_trace_is_buffered_and_cleaned_up(self):
+        proc = _processor()
+        aggregators = _FakeAggregatorPair()
+        proc.instrument_user_aggregator(aggregators, "conv-3")
+        aggregators.user().emit("early words")
+        # Pending entries are (sort_key, message) tuples.
+        assert (
+            proc._pending_user_transcripts["conv-3"][0][1]["content"] == 
"early words"
+        )
+
+        trace_id = 0x73
+        proc._remember_thread_id(trace_id, "conv-3")
+        assert proc._render_messages(trace_id)[0]["content"] == "early words"
+        assert "conv-3" not in proc._pending_user_transcripts
+
+        proc.on_end(_make_span("conversation", {"conversation.id": "conv-3"}, 
trace_id))
+        assert "conv-3" not in proc._trace_by_thread
+        assert trace_id not in proc._transcript_by_trace
+
+    def test_gemini_response_has_history_and_root_has_both_roles(self):
+        proc = _processor()
+        trace_id = 0x74
+        proc._remember_thread_id(trace_id, "conv-4")
+        aggregators = _FakeAggregatorPair()
+        proc.instrument_user_aggregator(aggregators, "conv-4")
+        aggregators.user().emit("hi there", "2026-01-01T00:00:01+00:00")
+
+        proc.on_end(
+            _make_span(
+                "llm_response",
+                {"output": "hello!"},
+                trace_id,
+                _ns("2026-01-01T00:00:02+00:00"),
+            )
+        )
+        response_attrs = _exported_attrs(proc)
+        assert json.loads(response_attrs["gen_ai.prompt"])["messages"] == [
+            {"role": "user", "content": "hi there"}
+        ]
+
+        proc.on_end(_make_span("conversation", {"conversation.id": "conv-4"}, 
trace_id))
+        root = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+        assert [(message["role"], message["content"]) for message in root] == [
+            ("user", "hi there"),
+            ("assistant", "hello!"),
+        ]
+
+    def 
test_user_from_aggregator_tools_from_snapshot_reply_from_response(self):
+        # The three sources compose into one ordered transcript: user 
(aggregator),
+        # tool call + result (llm_request snapshot), spoken reply 
(llm_response).
+        proc = _processor()
+        trace_id = 0x75
+        proc._remember_thread_id(trace_id, "conv-5")
+        aggregators = _FakeAggregatorPair()
+        proc.instrument_user_aggregator(aggregators, "conv-5")
+        aggregators.user().emit("weather?", "2026-01-01T00:00:01+00:00")
+        history = [
+            {"role": "system", "content": "be brief"},
+            {"role": "user", "content": "weather?"},
+            {
+                "role": "assistant",
+                "content": None,
+                "tool_calls": [
+                    {
+                        "id": "c1",
+                        "type": "function",
+                        "function": {"name": "weather", "arguments": "{}"},
+                    }
+                ],
+            },
+            {"role": "tool", "tool_call_id": "c1", "content": "sunny"},
+        ]
+        proc.on_end(
+            _make_span(
+                "llm_request",
+                {"input": json.dumps(history)},
+                trace_id,
+                _ns("2026-01-01T00:00:02+00:00"),
+            )
+        )
+        proc.on_end(
+            _make_span(
+                "llm_response",
+                {"output": "It is sunny."},
+                trace_id,
+                _ns("2026-01-01T00:00:03+00:00"),
+            )
+        )
+        proc.on_end(_make_span("conversation", {"conversation.id": "conv-5"}, 
trace_id))
+
+        prompt = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+        assert [m["role"] for m in prompt] == [
+            "user",
+            "assistant",
+            "tool",
+            "assistant",
+        ]
+        assert prompt[0]["content"] == "weather?"  # once, from the aggregator
+        assert prompt[1]["tool_calls"][0]["function"]["name"] == "weather"
+        assert prompt[3]["content"] == "It is sunny."
+
+    def test_snapshot_without_tools_contributes_nothing(self):
+        # A snapshot with only user / plain-assistant messages adds nothing: 
those
+        # roles come from the aggregator and llm_response, not llm_request.
+        proc = _processor()
+        trace_id = 0x76
+        proc._remember_thread_id(trace_id, "conv-6")
+        aggregators = _FakeAggregatorPair()
+        proc.instrument_user_aggregator(aggregators, "conv-6")
+        aggregators.user().emit("new question", "2026-01-01T00:00:01+00:00")
+
+        proc.on_end(
+            _make_span(
+                "llm_request",
+                {"input": json.dumps([{"role": "assistant", "content": 
"hello"}])},
+                trace_id,
+                _ns("2026-01-01T00:00:02+00:00"),
+            )
+        )
+        proc.on_end(
+            _make_span(
+                "llm_response",
+                {"output": "answer"},
+                trace_id,
+                _ns("2026-01-01T00:00:03+00:00"),
+            )
+        )
+
+        # The snapshot's "hello" is absent; only the aggregator user turn and 
the
+        # llm_response reply make up the transcript.
+        assert [(m["role"], m["content"]) for m in 
proc._render_messages(trace_id)] == [
+            ("user", "new question"),
+            ("assistant", "answer"),
+        ]
+
+    def test_async_user_turn_sorts_by_speech_time_not_arrival(self):
+        # The regression: OpenAI transcribes late, so the tool round-trip
+        # (llm_request) is processed BEFORE the user transcript arrives — but 
the
+        # user spoke first. Keying the user turn by its turn-start timestamp 
puts
+        # it ahead of the tool block, regardless of arrival order.
+        proc = _processor()
+        trace_id = 0x7A
+        proc._remember_thread_id(trace_id, "conv-async")
+        aggregators = _FakeAggregatorPair()
+        proc.instrument_user_aggregator(aggregators, "conv-async")
+
+        history = [
+            {
+                "role": "assistant",
+                "content": None,
+                "tool_calls": [
+                    {
+                        "id": "c1",
+                        "type": "function",
+                        "function": {"name": "weather", "arguments": "{}"},
+                    }
+                ],
+            },
+            {"role": "tool", "tool_call_id": "c1", "content": "sunny"},
+        ]
+        # Tool round-trip arrives first (t=3)...
+        proc.on_end(
+            _make_span(
+                "llm_request",
+                {"input": json.dumps(history)},
+                trace_id,
+                _ns("2026-01-01T00:00:03+00:00"),
+            )
+        )
+        # ...then the late user transcript, stamped when the user started 
(t=1).
+        aggregators.user().emit("weather?", "2026-01-01T00:00:01+00:00")
+        proc.on_end(
+            _make_span(
+                "llm_response",
+                {"output": "It is sunny."},
+                trace_id,
+                _ns("2026-01-01T00:00:04+00:00"),
+            )
+        )
+        proc.on_end(
+            _make_span("conversation", {"conversation.id": "conv-async"}, 
trace_id)
+        )
+
+        root = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+        assert [m["role"] for m in root] == ["user", "assistant", "tool", 
"assistant"]
+        assert root[0]["content"] == "weather?"  # ahead of the tool call it 
triggered
+
+    def test_multi_turn_order_and_empty_transcript(self):
+        proc = _processor()
+        trace_id = 0x77
+        proc._remember_thread_id(trace_id, "conv-7")
+        aggregators = _FakeAggregatorPair()
+        proc.instrument_user_aggregator(aggregators, "conv-7")
+        user = aggregators.user()
+
+        user.emit("")  # empty content is ignored
+        user.emit("first", "2026-01-01T00:00:01+00:00")
+        proc.on_end(
+            _make_span(
+                "llm_response",
+                {"output": "one"},
+                trace_id,
+                _ns("2026-01-01T00:00:02+00:00"),
+            )
+        )
+        user.emit("second", "2026-01-01T00:00:03+00:00")
+        proc.on_end(
+            _make_span(
+                "llm_response",
+                {"output": "two"},
+                trace_id,
+                _ns("2026-01-01T00:00:04+00:00"),
+            )
+        )
+
+        rollup = proc._render_messages(trace_id)
+        assert [(message["role"], message["content"]) for message in rollup] 
== [
+            ("user", "first"),
+            ("assistant", "one"),
+            ("user", "second"),
+            ("assistant", "two"),
+        ]
+        second_prompt = 
json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+        assert [(message["role"], message["content"]) for message in 
second_prompt] == [
+            ("user", "first"),
+            ("assistant", "one"),
+            ("user", "second"),
+        ]
+
+
 class TestPipecatUsageCapture:
     """Token/usage capture for cost: LLM usage + detail, realtime spans."""
 
@@ -682,21 +1234,30 @@
         completion = json.loads(attrs["gen_ai.completion"])["messages"]
         assert completion[0]["content"] == "the weather is sunny"
         # Reply folds into the conversation the root renders.
-        assert (
-            proc._transcript_by_trace[trace_id][-1]["content"] == "the weather 
is sunny"
-        )
+        assert proc._render_messages(trace_id)[-1]["content"] == "the weather 
is sunny"
 
     def test_realtime_rollup_has_user_and_agent_turns(self):
-        # Realtime has no cascade `llm` span carrying history: the user turn
-        # arrives on a standard `stt` span, the agent reply on `llm_response`.
-        # Both must fold into the conversation rollup the root renders.
+        # Without an instrumented aggregator, a finalized `stt` span folds the 
user
+        # turn and `llm_response` folds the agent reply; both roll up on the 
root.
         proc = _processor()
         trace_id = 0x56
-        proc.on_end(_make_span("stt", {"transcript": "what's the weather?"}, 
trace_id))
         proc.on_end(
-            _make_span("llm_response", {"output": "it's sunny"}, 
trace_id=trace_id)
+            _make_span(
+                "stt",
+                {"transcript": "what's the weather?"},
+                trace_id,
+                _ns("2026-01-01T00:00:01+00:00"),
+            )
+        )
+        proc.on_end(
+            _make_span(
+                "llm_response",
+                {"output": "it's sunny"},
+                trace_id,
+                _ns("2026-01-01T00:00:02+00:00"),
+            )
         )
-        rollup = proc._transcript_by_trace[trace_id]
+        rollup = proc._render_messages(trace_id)
         assert [(m["role"], m["content"]) for m in rollup] == [
             ("user", "what's the weather?"),
             ("assistant", "it's sunny"),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.5/uv.lock new/langsmith-0.10.6/uv.lock
--- old/langsmith-0.10.5/uv.lock        2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/uv.lock        2020-02-02 01:00:00.000000000 +0100
@@ -14,6 +14,9 @@
 exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included 
for backwards compatibility when using relative exclude-newer values.
 exclude-newer-span = "P1D"
 
+[manifest]
+overrides = [{ name = "json-repair", specifier = ">=0.60.1" }]
+
 [[package]]
 name = "aiofiles"
 version = "25.1.0"
@@ -1525,11 +1528,11 @@
 
 [[package]]
 name = "json-repair"
-version = "0.59.10"
+version = "0.61.4"
 source = { registry = "https://pypi.org/simple"; }
-sdist = { url = 
"https://files.pythonhosted.org/packages/d3/7c/e95bb03068572146eba37e8175c760f470ea0a6097310e16bbf2bc6e6457/json_repair-0.59.10.tar.gz";,
 hash = 
"sha256:2e4b85537c752d8a513ea28fdad891e5ede32c83de745366b97f648b8c34ede7", size 
= 49133, upload-time = "2026-05-14T06:41:51.222Z" }
+sdist = { url = 
"https://files.pythonhosted.org/packages/f0/f5/68b92610453eae5087a05a6f4123f0477dc2f3e84250c2d7de05552fa12a/json_repair-0.61.4.tar.gz";,
 hash = 
"sha256:d78c212c1d72606bee30a7886820c9d6f7dbd659883dc2397304735a59f7bf86", size 
= 51069, upload-time = "2026-07-12T16:51:11.036Z" }
 wheels = [
-    { url = 
"https://files.pythonhosted.org/packages/ee/87/49b20c6b81493d55c311f711ed87319d0fbad8bd0bbfbe36e52103af36bd/json_repair-0.59.10-py3-none-any.whl";,
 hash = 
"sha256:5468fa3eaadcc9b4a5646776bc4176e2fe5f374b5848a15f468cce3b60e3db0e", size 
= 47742, upload-time = "2026-05-14T06:41:49.812Z" },
+    { url = 
"https://files.pythonhosted.org/packages/f2/be/5b65e61de2c820e6e24837311249a98465d9b100db8da89f65068ad9e799/json_repair-0.61.4-py3-none-any.whl";,
 hash = 
"sha256:1056d5468a6d4e8bb4498b3244f996c795e2d457fbb30465d71249d3ef7b481a", size 
= 49604, upload-time = "2026-07-12T16:51:09.735Z" },
 ]
 
 [[package]]
@@ -2252,7 +2255,7 @@
 
 [[package]]
 name = "mcp"
-version = "1.26.0"
+version = "1.28.1"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
     { name = "anyio" },
@@ -2270,9 +2273,9 @@
     { name = "typing-inspection" },
     { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
 ]
-sdist = { url = 
"https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz";,
 hash = 
"sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size 
= 608005, upload-time = "2026-01-24T19:40:32.468Z" }
+sdist = { url = 
"https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz";,
 hash = 
"sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size 
= 638501, upload-time = "2026-06-26T12:57:29.093Z" }
 wheels = [
-    { url = 
"https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl";,
 hash = 
"sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size 
= 233615, upload-time = "2026-01-24T19:40:30.652Z" },
+    { url = 
"https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl";,
 hash = 
"sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size 
= 222620, upload-time = "2026-06-26T12:57:27.218Z" },
 ]
 
 [[package]]

Reply via email to