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-22 19:06:39
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-langsmith (Old)
 and      /work/SRC/openSUSE:Factory/.python-langsmith.new.24530 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-langsmith"

Wed Jul 22 19:06:39 2026 rev:13 rq:1367136 version:0.10.9

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-langsmith/python-langsmith.changes        
2026-07-21 23:09:14.435918277 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-langsmith.new.24530/python-langsmith.changes 
    2026-07-22 19:08:42.980479020 +0200
@@ -1,0 +2,5 @@
+Wed Jul 22 13:38:41 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 0.10.9
+
+-------------------------------------------------------------------

Old:
----
  langsmith-0.10.7.tar.gz

New:
----
  langsmith-0.10.9.tar.gz

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

Other differences:
------------------
++++++ python-langsmith.spec ++++++
--- /var/tmp/diff_new_pack.Q51sL8/_old  2026-07-22 19:08:43.448495055 +0200
+++ /var/tmp/diff_new_pack.Q51sL8/_new  2026-07-22 19:08:43.452495192 +0200
@@ -17,7 +17,7 @@
 
 
 Name:           python-langsmith
-Version:        0.10.7
+Version:        0.10.9
 Release:        0
 Summary:        Client library for the LangSmith LLM tracing and evaluation 
platform
 License:        MIT

++++++ langsmith-0.10.7.tar.gz -> langsmith-0.10.9.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.7/.bumpversion.cfg 
new/langsmith-0.10.9/.bumpversion.cfg
--- old/langsmith-0.10.7/.bumpversion.cfg       2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.10.9/.bumpversion.cfg       2020-02-02 01:00:00.000000000 
+0100
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.10.7
+current_version = 0.10.9
 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.7/PKG-INFO 
new/langsmith-0.10.9/PKG-INFO
--- old/langsmith-0.10.7/PKG-INFO       2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/PKG-INFO       2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: langsmith
-Version: 0.10.7
+Version: 0.10.9
 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.7/langsmith/__init__.py 
new/langsmith-0.10.9/langsmith/__init__.py
--- old/langsmith-0.10.7/langsmith/__init__.py  2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.10.9/langsmith/__init__.py  2020-02-02 01:00:00.000000000 
+0100
@@ -41,11 +41,15 @@
     from langsmith.run_trees import RunTree, configure
     from langsmith.testing._internal import test, unit
     from langsmith.utils import ContextThreadPoolExecutor
-    from langsmith.uuid import uuid7, uuid7_from_datetime
+    from langsmith.uuid import (
+        compute_run_id_for_secondary_replica,
+        uuid7,
+        uuid7_from_datetime,
+    )
 
 # Avoid calling into importlib on every call to __version__
 
-__version__ = "0.10.7"
+__version__ = "0.10.9"
 version = __version__  # for backwards compatibility
 
 # Metadata key to hide a traced run from LangSmith's Messages View.
@@ -144,6 +148,10 @@
         from langsmith.run_trees import configure
 
         return configure
+    elif name == "compute_run_id_for_secondary_replica":
+        from langsmith.uuid import compute_run_id_for_secondary_replica
+
+        return compute_run_id_for_secondary_replica
     elif name == "uuid7":
         from langsmith.uuid import uuid7
 
@@ -238,6 +246,7 @@
     "get_current_run_tree",
     "set_run_metadata",
     "ContextThreadPoolExecutor",
+    "compute_run_id_for_secondary_replica",
     "uuid7",
     "uuid7_from_datetime",
     "set_runtime_overrides",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/async_client.py 
new/langsmith-0.10.9/langsmith/async_client.py
--- old/langsmith-0.10.7/langsmith/async_client.py      2020-02-02 
01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/langsmith/async_client.py      2020-02-02 
01:00:00.000000000 +0100
@@ -52,6 +52,9 @@
     from langsmith._openapi_client.resources.online_evaluators import (
         AsyncOnlineEvaluatorsResource as AsyncEvaluatorsResource,
     )
+    from langsmith._openapi_client.resources.public.public import (
+        AsyncPublicResource,
+    )
     from langsmith._openapi_client.resources.sandboxes.sandboxes import (
         AsyncSandboxesResource,
     )
@@ -339,6 +342,11 @@
         """Access the traces resource (query, list_runs)."""
         return self._langsmith_api.traces
 
+    @property
+    def public(self) -> AsyncPublicResource:
+        """Access the public shared-run resource."""
+        return self._langsmith_api.public
+
     async def __aenter__(self) -> AsyncClient:
         """Enter the async client."""
         if self._cache is not None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/client.py 
new/langsmith-0.10.9/langsmith/client.py
--- old/langsmith-0.10.7/langsmith/client.py    2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.10.9/langsmith/client.py    2020-02-02 01:00:00.000000000 
+0100
@@ -272,6 +272,9 @@
     from langsmith._openapi_client.resources.online_evaluators import (
         AsyncOnlineEvaluatorsResource as AsyncEvaluatorsResource,
     )
+    from langsmith._openapi_client.resources.public.public import (
+        AsyncPublicResource,
+    )
     from langsmith._openapi_client.resources.runs import AsyncRunsResource
     from langsmith._openapi_client.resources.sandboxes.sandboxes import (
         AsyncSandboxesResource,
@@ -1524,6 +1527,12 @@
         _check_backend_version(self.info.version)
         return self._get_langsmith_api().traces
 
+    @property
+    def public(self) -> AsyncPublicResource:
+        """Access the public shared-run resource."""
+        _check_backend_version(self.info.version)
+        return self._get_langsmith_api().public
+
     def _dump_failed_trace(
         self,
         body_fn: Callable[[], bytes],
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/env/_runtime_env.py 
new/langsmith-0.10.9/langsmith/env/_runtime_env.py
--- old/langsmith-0.10.7/langsmith/env/_runtime_env.py  2020-02-02 
01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/langsmith/env/_runtime_env.py  2020-02-02 
01:00:00.000000000 +0100
@@ -177,6 +177,9 @@
         "LANGCHAIN_PROJECT",
         "LANGCHAIN_SESSION",
         "LANGSMITH_RUNS_ENDPOINTS",
+        # Control-plane signing secrets the substring filter misses; excluded 
here.
+        "LANGSMITH_SIGNING_JWKS",
+        "LANGSMITH_SANDBOX_CALLBACK_SIGNING_JWK",
     }
     langchain_metadata = {
         k: v
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.7/langsmith/integrations/strands_agents/exporter.py 
new/langsmith-0.10.9/langsmith/integrations/strands_agents/exporter.py
--- old/langsmith-0.10.7/langsmith/integrations/strands_agents/exporter.py      
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/langsmith/integrations/strands_agents/exporter.py      
2020-02-02 01:00:00.000000000 +0100
@@ -166,6 +166,7 @@
 
         input_messages: list[dict[str, Any]] = []
         output_messages: list[dict[str, Any]] = []
+        tool_output_completion: str | None = None
         remaining_events: list[Any] = []
 
         for event in span.events:
@@ -173,13 +174,31 @@
             attrs = dict(event.attributes) if event.attributes else {}
 
             if name == "gen_ai.choice":
-                # The final model response is the only true output
-                msg = self._event_to_message(
-                    name, attrs, tool_id_to_name=tool_id_to_name
-                )
-                if tool_call_id:
-                    msg["tool_call_id"] = tool_call_id
-                output_messages.append(msg)
+                if operation == "execute_tool":
+                    # In an 'execute_tool' span, Strands adds tool results
+                    # in a gen_ai.choice event. We convert it to expected
+                    # tool result block.
+                    raw = attrs.get("message", "[]")
+                    try:
+                        content = json.loads(raw) if isinstance(raw, str) else 
raw
+                    except (json.JSONDecodeError, TypeError):
+                        content = raw
+                    tool_output_completion = json.dumps(
+                        {
+                            "role": "tool",
+                            "content": self._extract_tool_output(content),
+                            "name": tool_name,
+                            "tool_call_id": tool_call_id,
+                        }
+                    )
+                else:
+                    # The final model response is the only true output
+                    msg = self._event_to_message(
+                        name, attrs, tool_id_to_name=tool_id_to_name
+                    )
+                    if tool_call_id:
+                        msg["tool_call_id"] = tool_call_id
+                    output_messages.append(msg)
             elif name in self._MESSAGE_EVENTS:
                 msg = self._event_to_message(
                     name, attrs, tool_id_to_name=tool_id_to_name
@@ -198,7 +217,9 @@
         new_attrs: dict[str, Any] = dict(span_attrs)
         if input_messages:
             new_attrs["gen_ai.prompt"] = json.dumps({"messages": 
input_messages})
-        if output_messages:
+        if tool_output_completion is not None:
+            new_attrs["gen_ai.completion"] = tool_output_completion
+        elif output_messages:
             new_attrs["gen_ai.completion"] = json.dumps(output_messages[-1])
 
         # Map gen_ai.operation.name to langsmith.span.kind (run type).
@@ -372,6 +393,35 @@
         return msg
 
     @staticmethod
+    def _extract_tool_output(content: Any) -> str:
+        """Extract plain-text output from tool result content blocks.
+
+        Strands tool spans emit their output as a list of content blocks
+        (e.g. ``[{"text": "Hippopotamus"}]``). This extracts the text and
+        returns it as a plain string for ``gen_ai.completion`` on tool spans.
+        """
+        if isinstance(content, str):
+            return content
+
+        if isinstance(content, list):
+            text_parts: list[str] = []
+            for block in content:
+                if isinstance(block, dict) and "text" in block and len(block) 
== 1:
+                    text_parts.append(block["text"])
+                else:
+                    try:
+                        text_parts.append(json.dumps(block, 
ensure_ascii=False))
+                    except (TypeError, ValueError):
+                        text_parts.append(str(block))
+            if text_parts:
+                return "\n".join(text_parts) if len(text_parts) > 1 else 
text_parts[0]
+
+        try:
+            return json.dumps(content, ensure_ascii=False)
+        except (TypeError, ValueError):
+            return str(content)
+
+    @staticmethod
     def _stringify_tool_content(content: Any) -> str:
         """Ensure tool message content is a string.
 
@@ -395,12 +445,14 @@
         Bedrock uses implicit typing (the key name *is* the type)::
 
             {"text": "hello"}
+            {"reasoningContent": {"reasoningText": {"text": "...", 
"signature": "..."}}}
             {"toolUse": {"toolUseId": "x", "name": "f", "input": {...}}}
             {"toolResult": {"toolUseId": "x", "status": "success", "content": 
[...]}}
 
         LangSmith expects explicit ``type`` fields::
 
             {"type": "text", "text": "hello"}
+            {"type": "thinking", "thinking": "...", "signature": "..."}
             {"type": "tool_use", "id": "x", "name": "f", "input": {...}}
             {
                 "type": "tool_result",
@@ -417,6 +469,15 @@
         if "text" in block and len(block) == 1:
             return {"type": "text", "text": block["text"]}
 
+        if "reasoningContent" in block:
+            rc = block["reasoningContent"]
+            reasoning_text = rc.get("reasoningText", {})
+            return {
+                "type": "thinking",
+                "thinking": reasoning_text.get("text", ""),
+                "signature": reasoning_text.get("signature", ""),
+            }
+
         if "toolUse" in block:
             tu = block["toolUse"]
             return {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/run_trees.py 
new/langsmith-0.10.9/langsmith/run_trees.py
--- old/langsmith-0.10.7/langsmith/run_trees.py 2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.10.9/langsmith/run_trees.py 2020-02-02 01:00:00.000000000 
+0100
@@ -57,6 +57,11 @@
     api_key: NotRequired[str]
     auth: AuthHeaders
     project_name: Optional[str]
+    primary: bool
+    """Whether this replica keeps the original run IDs.
+
+    When omitted, legacy project-based remapping behavior is preserved.
+    """
     updates: Optional[dict]
     client: Optional[Client]
     """Optional dedicated :class:`~langsmith.Client` for this replica.
@@ -72,7 +77,9 @@
     """
 
 
-_HEADER_SAFE_REPLICA_FIELDS: frozenset[str] = frozenset({"project_name", 
"updates"})
+_HEADER_SAFE_REPLICA_FIELDS: frozenset[str] = frozenset(
+    {"project_name", "primary", "updates"}
+)
 
 # Untrusted header-supplied replica `updates` is merged into the run, so 
restrict it
 # to a fail-closed allow-list. `reroot` (re-parenting control) is always kept;
@@ -126,6 +133,8 @@
         for key, value in replica.items()
         if key in _HEADER_SAFE_REPLICA_FIELDS
     }
+    if "primary" in filtered and not isinstance(filtered["primary"], bool):
+        filtered.pop("primary")
     if "updates" in filtered:
         filtered["updates"] = _sanitize_header_updates(filtered.get("updates"))
     return cast(WriteReplica, filtered)
@@ -702,11 +711,15 @@
             run_dict.pop("parent_run_id", None)
 
     def _remap_for_project(
-        self, project_name: str, updates: Optional[dict] = None
+        self,
+        project_name: str,
+        updates: Optional[dict] = None,
+        *,
+        primary: Optional[bool] = None,
     ) -> dict:
         """Rewrites ids/dotted_order for a given project with optional 
updates."""
         run_dict = self._get_dicts_safe()
-        if project_name == self.session_name:
+        if primary is None and project_name == self.session_name:
             return run_dict
 
         if updates and updates.get("reroot", False):
@@ -714,6 +727,13 @@
             if distributed_parent_id:
                 self._slice_parent_id(distributed_parent_id, run_dict)
 
+        if primary:
+            dup = utils.deepish_copy(run_dict)
+            dup["session_name"] = project_name
+            if updates:
+                dup.update(updates)
+            return dup
+
         old_id = run_dict["id"]
         new_id = uuid7_deterministic(UUID(str(old_id)), project_name)
         # trace id
@@ -760,7 +780,9 @@
             for replica in self.replicas:
                 project_name = replica.get("project_name") or self.session_name
                 updates = replica.get("updates")
-                run_dict = self._remap_for_project(project_name, updates)
+                run_dict = self._remap_for_project(
+                    project_name, updates, primary=replica.get("primary")
+                )
                 api_url, api_key, service_key, tenant_id, authorization, 
cookie = (
                     _extract_replica_auth(replica)
                 )
@@ -829,7 +851,9 @@
             for replica in self.replicas:
                 project_name = replica.get("project_name") or self.session_name
                 updates = replica.get("updates")
-                run_dict = self._remap_for_project(project_name, updates)
+                run_dict = self._remap_for_project(
+                    project_name, updates, primary=replica.get("primary")
+                )
                 api_url, api_key, service_key, tenant_id, authorization, 
cookie = (
                     _extract_replica_auth(replica)
                 )
@@ -1188,6 +1212,8 @@
                 api_url = item.get("api_url")
                 api_key = item.get("api_key")
                 project_name = item.get("project_name")
+                primary = item.get("primary")
+                has_primary = "primary" in item
 
                 if not isinstance(api_url, str):
                     logger.warning(
@@ -1210,14 +1236,22 @@
                     )
                     continue
 
-                replicas.append(
-                    WriteReplica(
-                        api_url=api_url.rstrip("/"),
-                        auth=AuthHeaders(api_key=api_key),
-                        project_name=project_name,
-                        updates=None,
+                if has_primary and not isinstance(primary, bool):
+                    logger.warning(
+                        f"Invalid primary type in LANGSMITH_RUNS_ENDPOINTS: "
+                        f"expected boolean, got {type(primary).__name__}"
                     )
+                    continue
+
+                replica = WriteReplica(
+                    api_url=api_url.rstrip("/"),
+                    auth=AuthHeaders(api_key=api_key),
+                    project_name=project_name,
+                    updates=None,
                 )
+                if has_primary:
+                    replica["primary"] = cast(bool, primary)
+                replicas.append(replica)
             return replicas
         elif isinstance(parsed, dict):
             _check_endpoint_env_unset(parsed)
@@ -1283,11 +1317,10 @@
     replicas: Optional[Sequence[WriteReplica]],
 ) -> list[WriteReplica]:
     """Convert replicas to WriteReplica format."""
-    if replicas is None:
-        return _get_write_replicas_from_env()
-
-    # All replicas should now be WriteReplica dicts
-    return list(replicas)
+    ensured = _get_write_replicas_from_env() if replicas is None else 
list(replicas)
+    if sum(replica.get("primary") is True for replica in ensured) > 1:
+        raise ValueError("Only one replica can be marked as primary.")
+    return ensured
 
 
 def _parse_dotted_order(dotted_order: str) -> list[tuple[datetime, UUID]]:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/uuid.py 
new/langsmith-0.10.9/langsmith/uuid.py
--- old/langsmith-0.10.7/langsmith/uuid.py      2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.10.9/langsmith/uuid.py      2020-02-02 01:00:00.000000000 
+0100
@@ -9,6 +9,7 @@
 import uuid as _uuid
 
 from ._internal._uuid import uuid7 as _uuid7
+from ._internal._uuid import uuid7_deterministic as _uuid7_deterministic
 
 
 def uuid7() -> _uuid.UUID:
@@ -35,4 +36,29 @@
     return _uuid7(nanoseconds)
 
 
-__all__ = ["uuid7", "uuid7_from_datetime"]
+def compute_run_id_for_secondary_replica(
+    run_id: _uuid.UUID | str, project_name: str
+) -> _uuid.UUID:
+    """Compute the run ID used for a secondary tracing replica.
+
+    Args:
+        run_id: The original UUID v7 run ID.
+        project_name: The secondary replica's destination project name.
+
+    Returns:
+        uuid.UUID: The run ID used in the secondary replica destination.
+
+    Raises:
+        ValueError: If ``run_id`` is not UUID v7, or if ``project_name`` is
+            empty or invalid.
+    """
+    if not isinstance(project_name, str) or not project_name:
+        raise ValueError("project_name must be a non-empty string")
+
+    parsed_run_id = _uuid.UUID(str(run_id))
+    if parsed_run_id.version != 7:
+        raise ValueError("run_id must be a UUID v7")
+    return _uuid7_deterministic(parsed_run_id, project_name)
+
+
+__all__ = ["compute_run_id_for_secondary_replica", "uuid7", 
"uuid7_from_datetime"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.7/tests/integration_tests/test_v2_public_runs.py 
new/langsmith-0.10.9/tests/integration_tests/test_v2_public_runs.py
--- old/langsmith-0.10.7/tests/integration_tests/test_v2_public_runs.py 
1970-01-01 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/integration_tests/test_v2_public_runs.py 
2020-02-02 01:00:00.000000000 +0100
@@ -0,0 +1,151 @@
+"""Smoke integration tests for the v2 public.runs resource.
+
+Covers ``Client.public.runs`` (retrieve, query) against a live backend. Public
+shared-run reads are authenticated by the share token in the path; the client's
+API key is created only to mint that token via ``runs.share.create``.
+"""
+
+import datetime
+import logging
+import time
+from typing import Any, Callable
+
+import pytest
+
+from langsmith import uuid7
+from langsmith.client import Client
+from langsmith.utils import get_env_var
+
+logger = logging.getLogger(__name__)
+
+# Public run fields to request; START_TIME is needed as the retrieve 
coordinate.
+_SELECTS = ["ID", "NAME", "RUN_TYPE", "STATUS", "START_TIME"]
+
+
+def _wait_for_sync(
+    condition: Callable[[], bool],
+    max_sleep_time: int = 60,
+    sleep_time: int = 3,
+) -> None:
+    start = time.time()
+    while time.time() - start < max_sleep_time:
+        try:
+            if condition():
+                return
+        except Exception:
+            logger.debug("Error checking sync condition", exc_info=True)
+        time.sleep(sleep_time)
+    raise ValueError(f"Condition not met within {max_sleep_time}s")
+
+
+async def _wait_for_async(
+    condition: Callable[[], Any],
+    max_sleep_time: int = 60,
+    sleep_time: int = 3,
+) -> Any:
+    """Poll `condition` until it returns a truthy value, then return it."""
+    start = time.time()
+    while time.time() - start < max_sleep_time:
+        try:
+            result = await condition()
+            if result:
+                return result
+        except Exception:
+            logger.debug("Error checking async condition", exc_info=True)
+        time.sleep(sleep_time)
+    raise ValueError(f"Condition not met within {max_sleep_time}s")
+
+
[email protected]
+def langchain_client() -> Client:
+    get_env_var.cache_clear()
+    return Client()
+
+
[email protected]
+async def shared_run(langchain_client: Client):
+    """Create a project with one root run and share it.
+
+    Yields a dict with run_id, project_id, and share_token.
+    """
+    project_name = f"__test_v2_public_runs_{uuid7().hex[:12]}"
+    if langchain_client.has_project(project_name=project_name):
+        langchain_client.delete_project(project_name=project_name)
+
+    run_id = str(uuid7())
+    now = datetime.datetime.now(datetime.timezone.utc)
+    langchain_client.create_run(
+        id=run_id,
+        name="run_1",
+        inputs={"i": 1},
+        run_type="llm",
+        project_name=project_name,
+        start_time=now,
+    )
+
+    def _run_indexed() -> bool:
+        return (
+            next(langchain_client.list_runs(project_name=project_name, 
limit=1), None)
+            is not None
+        )
+
+    _wait_for_sync(_run_indexed, max_sleep_time=90, sleep_time=2)
+
+    run = langchain_client.read_run(run_id)
+    project = langchain_client.read_project(project_name=project_name)
+    project_id = str(project.id)
+    trace_id = str(run.trace_id) if run.trace_id else run_id
+
+    async def _create_share():
+        return await langchain_client.runs.share.create(
+            run_id, session_id=project_id, trace_id=trace_id
+        )
+
+    # share.create resolves the trace root from SmithDB, which may lag 
indexing.
+    resp = await _wait_for_async(_create_share, max_sleep_time=90)
+
+    try:
+        yield {
+            "run_id": run_id,
+            "project_id": project_id,
+            "share_token": resp.share_token,
+        }
+    finally:
+        if langchain_client.has_project(project_name=project_name):
+            langchain_client.delete_project(project_name=project_name)
+
+
+async def test_public_runs_query(langchain_client: Client, shared_run) -> None:
+    """public.runs.query() returns the shared trace's runs by share token."""
+    share_token = shared_run["share_token"]
+    run_id = shared_run["run_id"]
+
+    async def _ready():
+        page = await langchain_client.public.runs.query(share_token, 
selects=_SELECTS)
+        return page if any(str(i.id) == run_id for i in page.items) else None
+
+    page = await _wait_for_async(_ready, max_sleep_time=90)
+    assert run_id in {str(i.id) for i in page.items}
+
+
+async def test_public_runs_retrieve(langchain_client: Client, shared_run) -> 
None:
+    """public.runs.retrieve() returns a single run by (share token, id, 
start_time)."""
+    share_token = shared_run["share_token"]
+    run_id = shared_run["run_id"]
+
+    async def _retrieve():
+        # Derive the exact start_time coordinate from the public read path 
itself,
+        # so it matches whatever the backend stored (retrieve is a point 
lookup).
+        page = await langchain_client.public.runs.query(share_token, 
selects=_SELECTS)
+        item = next((i for i in page.items if str(i.id) == run_id), None)
+        if item is None or item.start_time is None:
+            return None
+        return await langchain_client.public.runs.retrieve(
+            run_id,
+            share_token=share_token,
+            selects=_SELECTS,
+            start_time=item.start_time,
+        )
+
+    run = await _wait_for_async(_retrieve, max_sleep_time=90)
+    assert str(run.id) == run_id
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.7/tests/integration_tests/test_v2_runs_share.py 
new/langsmith-0.10.9/tests/integration_tests/test_v2_runs_share.py
--- old/langsmith-0.10.7/tests/integration_tests/test_v2_runs_share.py  
1970-01-01 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/integration_tests/test_v2_runs_share.py  
2020-02-02 01:00:00.000000000 +0100
@@ -0,0 +1,141 @@
+"""Smoke integration tests for the v2 runs.share resource.
+
+Covers ``Client.runs.share`` (create, delete) against a live backend.
+"""
+
+import datetime
+import logging
+import time
+import uuid
+from typing import Any, Callable
+
+import pytest
+
+from langsmith import uuid7
+from langsmith.client import Client
+from langsmith.utils import get_env_var
+
+logger = logging.getLogger(__name__)
+
+
+def _wait_for_sync(
+    condition: Callable[[], bool],
+    max_sleep_time: int = 60,
+    sleep_time: int = 3,
+) -> None:
+    start = time.time()
+    while time.time() - start < max_sleep_time:
+        try:
+            if condition():
+                return
+        except Exception:
+            logger.debug("Error checking sync condition", exc_info=True)
+        time.sleep(sleep_time)
+    raise ValueError(f"Condition not met within {max_sleep_time}s")
+
+
+async def _wait_for_async(
+    condition: Callable[[], Any],
+    max_sleep_time: int = 60,
+    sleep_time: int = 3,
+) -> Any:
+    """Poll `condition` until it returns a truthy value, then return it."""
+    start = time.time()
+    while time.time() - start < max_sleep_time:
+        try:
+            result = await condition()
+            if result:
+                return result
+        except Exception:
+            logger.debug("Error checking async condition", exc_info=True)
+        time.sleep(sleep_time)
+    raise ValueError(f"Condition not met within {max_sleep_time}s")
+
+
[email protected]
+def langchain_client() -> Client:
+    get_env_var.cache_clear()
+    return Client()
+
+
[email protected]
+def project_with_run(langchain_client: Client):
+    """Create a project with one root run.
+
+    Yields a dict with run_id, trace_id, and project_id (== session_id).
+    """
+    project_name = f"__test_v2_runs_share_{uuid7().hex[:12]}"
+    if langchain_client.has_project(project_name=project_name):
+        langchain_client.delete_project(project_name=project_name)
+
+    run_id = str(uuid7())
+    now = datetime.datetime.now(datetime.timezone.utc)
+    langchain_client.create_run(
+        id=run_id,
+        name="run_1",
+        inputs={"i": 1},
+        run_type="llm",
+        project_name=project_name,
+        start_time=now,
+    )
+
+    def _run_indexed() -> bool:
+        return (
+            next(langchain_client.list_runs(project_name=project_name, 
limit=1), None)
+            is not None
+        )
+
+    _wait_for_sync(_run_indexed, max_sleep_time=90, sleep_time=2)
+
+    run = langchain_client.read_run(run_id)
+    project = langchain_client.read_project(project_name=project_name)
+    trace_id = str(run.trace_id) if run.trace_id else run_id
+    try:
+        yield {
+            "run_id": run_id,
+            "trace_id": trace_id,
+            "project_id": str(project.id),
+        }
+    finally:
+        if langchain_client.has_project(project_name=project_name):
+            langchain_client.delete_project(project_name=project_name)
+
+
+async def test_runs_share_create(langchain_client: Client, project_with_run) 
-> None:
+    """runs.share.create() mints a valid share token for a run's trace root."""
+    run = project_with_run
+
+    async def _create():
+        return await langchain_client.runs.share.create(
+            run["run_id"],
+            session_id=run["project_id"],
+            trace_id=run["trace_id"],
+        )
+
+    # share.create resolves the trace root from SmithDB, which may lag 
indexing.
+    resp = await _wait_for_async(_create, max_sleep_time=90)
+    # Raises ValueError if the token is not a valid UUID.
+    assert uuid.UUID(resp.share_token)
+
+
+async def test_runs_share_delete(langchain_client: Client, project_with_run) 
-> None:
+    """runs.share.delete() removes the share token and is idempotent."""
+    run = project_with_run
+
+    async def _create():
+        return await langchain_client.runs.share.create(
+            run["run_id"],
+            session_id=run["project_id"],
+            trace_id=run["trace_id"],
+        )
+
+    await _wait_for_async(_create, max_sleep_time=90)
+
+    # Delete returns None (204) and is idempotent: a second delete also
+    # succeeds. Success is "does not raise".
+    await langchain_client.runs.share.delete(
+        run["trace_id"], session_id=run["project_id"]
+    )
+    await langchain_client.runs.share.delete(
+        run["trace_id"], session_id=run["project_id"]
+    )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.7/tests/integration_tests/wrappers/test_strands_agents.py 
new/langsmith-0.10.9/tests/integration_tests/wrappers/test_strands_agents.py
--- 
old/langsmith-0.10.7/tests/integration_tests/wrappers/test_strands_agents.py    
    2020-02-02 01:00:00.000000000 +0100
+++ 
new/langsmith-0.10.9/tests/integration_tests/wrappers/test_strands_agents.py    
    2020-02-02 01:00:00.000000000 +0100
@@ -19,6 +19,7 @@
     SpanExportResult,
 )
 from strands import Agent
+from strands.models.bedrock import BedrockModel
 from strands_tools import file_read, file_write, journal, python_repl, shell
 
 from langsmith.integrations.otel.processor import OtelExporter
@@ -183,3 +184,104 @@
     roles = [message["role"] for message in prompt["messages"]]
     assert "user" in roles
     assert "system" in roles
+
+    tool_spans = [
+        span for span in recorder.spans if span.name.startswith("execute_tool")
+    ]
+    assert tool_spans, [span.name for span in recorder.spans]
+    for tool_span in tool_spans:
+        assert tool_span.attributes["langsmith.span.kind"] == "tool"
+        assert _span_has_completion(tool_span)
+        completion = json.loads(tool_span.attributes["gen_ai.completion"])
+        assert completion["role"] == "tool"
+        assert "content" in completion
+        assert "name" in completion
+        assert "tool_call_id" in completion
+
+
[email protected](
+    not (
+        os.getenv("AWS_ACCESS_KEY_ID")
+        or os.getenv("AWS_PROFILE")
+        or os.getenv("AWS_WEB_IDENTITY_TOKEN_FILE")
+    ),
+    reason="Live Strands/Bedrock integration test requires AWS credentials.",
+)
[email protected](
+    not os.getenv("LANGSMITH_API_KEY"),
+    reason="Live Strands/LangSmith integration test requires 
LANGSMITH_API_KEY.",
+)
+def test_exporter_transforms_thinking_blocks(tmp_path, monkeypatch):
+    """Invoke a Strands Agent with extended thinking and verify thinking 
blocks."""
+    (tmp_path / "otel_strands_share.py").write_text(
+        "def setup_langsmith_telemetry():\n"
+        "    return 'sets up Strands OTEL tracing for LangSmith'\n",
+        encoding="utf-8",
+    )
+    monkeypatch.chdir(tmp_path)
+
+    recorder = RecordingSpanExporter()
+    langsmith_exporter = OtelExporter()
+    delegate = TeeSpanExporter(recorder, langsmith_exporter)
+    provider = TracerProvider()
+    provider.add_span_processor(
+        SimpleSpanProcessor(LangSmithSpanExporter(delegate=delegate))
+    )
+
+    import strands.telemetry.tracer as strands_tracer
+
+    previous_tracer = _install_strands_tracer(provider)
+    try:
+        model = BedrockModel(
+            model_id=MODEL_ID,
+            additional_request_fields={
+                "thinking": {"type": "enabled", "budget_tokens": 5000}
+            },
+            max_tokens=16000,
+        )
+        agent = Agent(
+            model=model,
+            tools=[file_read],
+            system_prompt=SYSTEM_PROMPT,
+        )
+
+        response = agent(INPUT)
+        provider.force_flush()
+    finally:
+        provider.shutdown()
+        strands_tracer._tracer_instance = previous_tracer
+
+    assert response is not None
+
+    llm_spans = [span for span in recorder.spans if span.name == "chat"]
+    assert llm_spans, [span.name for span in recorder.spans]
+    assert any(_span_has_prompt_text(span, INPUT) for span in llm_spans)
+    assert any(_span_has_completion(span) for span in llm_spans)
+
+    for llm_span in llm_spans:
+        assert llm_span.attributes["langsmith.span.kind"] == "llm"
+        assert llm_span.attributes["gen_ai.request.model"] == MODEL_ID
+        assert llm_span.attributes["langsmith.metadata.ls_provider"] == 
"amazon_bedrock"
+        assert llm_span.attributes["langsmith.metadata.ls_model_type"] == 
"chat"
+        assert not any(event.name.startswith("gen_ai.") for event in 
llm_span.events)
+
+    found_thinking = False
+    for span in llm_spans:
+        completion_str = span.attributes.get("gen_ai.completion")
+        if not isinstance(completion_str, str):
+            continue
+        completion = json.loads(completion_str)
+        messages = completion if isinstance(completion, list) else [completion]
+        for msg in messages:
+            content = msg.get("content", [])
+            if isinstance(content, list):
+                for block in content:
+                    if isinstance(block, dict) and block.get("type") == 
"thinking":
+                        found_thinking = True
+                        assert isinstance(block["thinking"], str)
+                        assert len(block["thinking"]) > 0
+
+    assert found_thinking, (
+        "Expected at least one thinking block in LLM completions. "
+        f"Spans checked: {len(llm_spans)}"
+    )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.7/tests/unit_tests/test_env.py 
new/langsmith-0.10.9/tests/unit_tests/test_env.py
--- old/langsmith-0.10.7/tests/unit_tests/test_env.py   2020-02-02 
01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/unit_tests/test_env.py   2020-02-02 
01:00:00.000000000 +0100
@@ -1,7 +1,7 @@
 import pytest
 
 from langsmith.env import __all__ as env_all
-from langsmith.env import get_git_info
+from langsmith.env import get_git_info, get_langchain_env_var_metadata
 
 _EXPECTED = [
     "get_docker_compose_command",
@@ -32,3 +32,20 @@
         assert "langsmith-sdk" in git_info["remote_url"]
     except AssertionError:
         pytest.skip("Git information is not available, skipping test.")
+
+
[email protected](
+    "secret_var",
+    ["LANGSMITH_SIGNING_JWKS", "LANGSMITH_SANDBOX_CALLBACK_SIGNING_JWK"],
+)
+def test_env_var_metadata_excludes_signing_secrets(
+    monkeypatch: pytest.MonkeyPatch, secret_var: str
+) -> None:
+    monkeypatch.setenv(secret_var, "super-secret-value")
+    monkeypatch.setenv("LANGSMITH_LANGGRAPH_API_VARIANT", "local")
+    monkeypatch.setenv("LANGCHAIN_REVISION_ID", "abc123")
+    get_langchain_env_var_metadata.cache_clear()
+    metadata = get_langchain_env_var_metadata()
+    assert secret_var not in metadata
+    assert metadata["LANGSMITH_LANGGRAPH_API_VARIANT"] == "local"
+    assert metadata["revision_id"] == "abc123"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.7/tests/unit_tests/test_replica_endpoints.py 
new/langsmith-0.10.9/tests/unit_tests/test_replica_endpoints.py
--- old/langsmith-0.10.7/tests/unit_tests/test_replica_endpoints.py     
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/unit_tests/test_replica_endpoints.py     
2020-02-02 01:00:00.000000000 +0100
@@ -8,7 +8,7 @@
 
 import pytest
 
-from langsmith import Client
+from langsmith import Client, compute_run_id_for_secondary_replica
 from langsmith import utils as ls_utils
 from langsmith.run_trees import (
     ApiKeyAuth,
@@ -77,6 +77,15 @@
         assert result[1]["auth"]["api_key"] == "key2"
         assert result[1].get("project_name") is None
 
+    def test_ensure_write_replicas_rejects_multiple_primary_replicas(self):
+        replicas = [
+            WriteReplica(project_name="primary-1", primary=True),
+            WriteReplica(project_name="primary-2", primary=True),
+        ]
+
+        with pytest.raises(ValueError, match="Only one replica"):
+            _ensure_write_replicas(replicas)
+
 
 class TestEnvironmentVariableParsing:
     """Test environment variable parsing for LANGSMITH_RUNS_ENDPOINTS."""
@@ -357,11 +366,13 @@
                     "api_url": "https://api.example.com";,
                     "api_key": "key1",
                     "project_name": "project-prod",
+                    "primary": True,
                 },
                 {
                     "api_url": "https://api.example.com";,
                     "api_key": "key2",
                     "project_name": "project-staging",
+                    "primary": False,
                 },
                 {"api_url": "https://api.example.com";, "api_key": "key3"},
             ]
@@ -386,6 +397,30 @@
             None,
         ]
         assert all(r["updates"] is None for r in result)
+        assert [r.get("primary") for r in result] == [True, False, None]
+
+    @pytest.mark.parametrize("primary", ["true", 1, None])
+    def test_parse_new_array_format_rejects_invalid_primary(self, primary):
+        env_var = json.dumps(
+            [
+                {
+                    "api_url": "https://invalid.example.com";,
+                    "api_key": "invalid-key",
+                    "primary": primary,
+                },
+                {
+                    "api_url": "https://valid.example.com";,
+                    "api_key": "valid-key",
+                    "primary": False,
+                },
+            ]
+        )
+
+        result = _parse_write_replicas_from_env_var(env_var)
+
+        assert len(result) == 1
+        assert result[0]["api_url"] == "https://valid.example.com";
+        assert result[0]["primary"] is False
 
     def test_parse_object_format(self):
         """Test parsing object format."""
@@ -740,6 +775,7 @@
                 api_url="https://replica1.example.com";,
                 auth=ApiKeyAuth(api_key="replica1-key"),
                 project_name="replica1-project",
+                primary=True,
             ),
             WriteReplica(
                 api_url="https://replica2.example.com";,
@@ -766,6 +802,10 @@
         assert calls[0][1]["api_url"] == "https://replica1.example.com";
         assert calls[1][1]["api_key"] == "replica2-key"
         assert calls[1][1]["api_url"] == "https://replica2.example.com";
+        assert calls[0][1]["id"] == run_tree.id
+        assert calls[1][1]["id"] == compute_run_id_for_secondary_replica(
+            run_tree.id, "replica2-project"
+        )
 
     def test_run_tree_with_service_auth_replicas(self):
         """Test RunTree with ServiceAuth replicas."""
@@ -825,6 +865,9 @@
         call_args = client.update_run.call_args
         assert call_args[1]["api_key"] == "replica-key"
         assert call_args[1]["api_url"] == "https://replica.example.com";
+        assert call_args[1]["run_id"] == compute_run_id_for_secondary_replica(
+            run_tree.id, "replica-project"
+        )
 
     def test_run_tree_preserves_env_replica_project_names(self):
         client = Mock()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.7/tests/unit_tests/test_run_trees.py 
new/langsmith-0.10.9/tests/unit_tests/test_run_trees.py
--- old/langsmith-0.10.7/tests/unit_tests/test_run_trees.py     2020-02-02 
01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/unit_tests/test_run_trees.py     2020-02-02 
01:00:00.000000000 +0100
@@ -343,10 +343,18 @@
     root = RunTree(name="Root", inputs={}, client=mock_client, 
session_name="original")
     child = root.create_child(name="Child")
 
-    # Same project: no remapping
+    # Omitted primary preserves legacy same-project behavior.
     same = child._remap_for_project("original")
     assert same["id"] == child.id
 
+    # Explicit primary preserves IDs regardless of project.
+    primary = child._remap_for_project("replica", primary=True)
+    assert primary["id"] == child.id
+
+    # Explicit non-primary remaps even within the same project.
+    non_primary = child._remap_for_project("original", primary=False)
+    assert non_primary["id"] == uuid7_deterministic(child.id, "original")
+
     # Different project: IDs remapped deterministically
     r1 = child._remap_for_project("replica")
     r2 = child._remap_for_project("replica")
@@ -531,6 +539,7 @@
                 "api_key": "injected-key",
                 "api_url": "https://evil.com/exfil";,
                 "project_name": "legit-project",
+                "primary": True,
                 "updates": {"reroot": True},
             }
         ]
@@ -550,9 +559,30 @@
     assert "api_key" not in replica
     assert "api_url" not in replica
     assert replica.get("project_name") == "legit-project"
+    assert replica.get("primary") is True
     assert replica.get("updates") == {"reroot": True}
 
 
+def test_from_headers_drops_non_boolean_replica_primary():
+    replicas_json = json.dumps(
+        [{"project_name": "replica-project", "primary": "false"}]
+    )
+    baggage = f"langsmith-replicas={urllib.parse.quote(replicas_json)}"
+    headers = {
+        "langsmith-trace": 
"20240101T000000000000Z00000000-0000-0000-0000-000000000001",
+        "baggage": baggage,
+    }
+
+    parsed = RunTree.from_headers(headers)
+
+    assert parsed is not None
+    assert parsed.replicas is not None
+    replica = parsed.replicas[0]
+    assert replica.get("primary") is None
+    remapped = parsed._remap_for_project(replica["project_name"])
+    assert remapped["id"] != parsed.id
+
+
 def test_from_headers_allowlists_update_fields():
     """Only allow-listed fields survive in an untrusted baggage replica's 
`updates`."""
     replicas_json = json.dumps(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.10.7/tests/unit_tests/test_uuid_v7.py 
new/langsmith-0.10.9/tests/unit_tests/test_uuid_v7.py
--- old/langsmith-0.10.7/tests/unit_tests/test_uuid_v7.py       2020-02-02 
01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/unit_tests/test_uuid_v7.py       2020-02-02 
01:00:00.000000000 +0100
@@ -2,6 +2,9 @@
 from typing import Any
 from unittest.mock import MagicMock
 
+import pytest
+
+from langsmith import compute_run_id_for_secondary_replica
 from langsmith._internal._uuid import uuid7, uuid7_deterministic
 from langsmith.client import Client
 from langsmith.run_helpers import traceable
@@ -94,6 +97,35 @@
     assert update_kwargs["start_time"] == fixed_start
 
 
+def test_compute_run_id_for_secondary_replica() -> None:
+    original = "019c0711-e1aa-7223-bf21-12119afe80f7"
+
+    remapped = compute_run_id_for_secondary_replica(original, 
"replica-project")
+
+    assert str(remapped) == "019c0711-e1aa-7817-8301-943c0df9a3cd"
+
+    with pytest.raises(ValueError, match="project_name"):
+        compute_run_id_for_secondary_replica(original, "")
+    with pytest.raises(ValueError, match="UUID v7"):
+        compute_run_id_for_secondary_replica(
+            "12345678-1234-4234-8234-123456789abc", "replica-project"
+        )
+
+
+def test_internal_replica_remapping_accepts_non_uuid7() -> None:
+    original = "12345678-1234-4234-8234-123456789abc"
+    run_tree = RunTree(
+        id=original,
+        name="test",
+        inputs={},
+        project_name="original-project",
+    )
+
+    remapped = run_tree._remap_for_project("replica-project", primary=False)
+
+    assert remapped["id"].version == 7
+
+
 def test_uuid7_deterministic_produces_expected_values() -> None:
     """Test with hard-coded values to ensure compatibility across 
implementations.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.10.7/tests/unit_tests/wrappers/test_strands_agents.py 
new/langsmith-0.10.9/tests/unit_tests/wrappers/test_strands_agents.py
--- old/langsmith-0.10.7/tests/unit_tests/wrappers/test_strands_agents.py       
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/unit_tests/wrappers/test_strands_agents.py       
2020-02-02 01:00:00.000000000 +0100
@@ -267,6 +267,166 @@
         assert result["status"] == "success"
         assert result["content"] == [{"type": "text", "text": "result"}]
 
+    def test_convert_content_block_reasoning(self):
+        """Test conversion of reasoningContent content blocks."""
+        block = {
+            "reasoningContent": {
+                "reasoningText": {
+                    "text": "Let me break down the problem step by step.",
+                    "signature": "Ev4Q_signature_data",
+                }
+            }
+        }
+        result = LangSmithSpanExporter._convert_content_block(block)
+
+        assert result == {
+            "type": "thinking",
+            "thinking": "Let me break down the problem step by step.",
+            "signature": "Ev4Q_signature_data",
+        }
+
+    def test_convert_content_block_reasoning_missing_signature(self):
+        """Test conversion of reasoningContent blocks without signature."""
+        block = {
+            "reasoningContent": {
+                "reasoningText": {
+                    "text": "Thinking about this...",
+                }
+            }
+        }
+        result = LangSmithSpanExporter._convert_content_block(block)
+
+        assert result == {
+            "type": "thinking",
+            "thinking": "Thinking about this...",
+            "signature": "",
+        }
+
+    def test_convert_content_block_reasoning_empty(self):
+        """Test conversion of reasoningContent blocks with missing 
reasoningText."""
+        block = {"reasoningContent": {}}
+        result = LangSmithSpanExporter._convert_content_block(block)
+
+        assert result == {
+            "type": "thinking",
+            "thinking": "",
+            "signature": "",
+        }
+
+    def test_transform_tool_span_choice_outputs_tool_envelope(self):
+        """Test that execute_tool span choice events produce tool output 
envelope."""
+        delegate = MagicMock()
+        delegate.export.return_value = 0
+
+        exporter = LangSmithSpanExporter(delegate=delegate)
+
+        choice_event = self._make_event(
+            "gen_ai.choice",
+            {
+                "message": json.dumps([{"text": "Hippopotamus"}]),
+                "id": "tooluse_5lnW6BJJLFew8zk9eFpVUt",
+            },
+        )
+        span = self._make_span(
+            name="execute_tool get_magic_word",
+            attributes={
+                "gen_ai.operation.name": "execute_tool",
+                "gen_ai.tool.name": "get_magic_word",
+                "gen_ai.tool.call.id": "tooluse_5lnW6BJJLFew8zk9eFpVUt",
+            },
+            events=[choice_event],
+        )
+
+        exporter.export([span])
+        transformed = delegate.export.call_args[0][0][0]
+
+        completion = json.loads(transformed.attributes["gen_ai.completion"])
+        assert completion == {
+            "role": "tool",
+            "content": "Hippopotamus",
+            "name": "get_magic_word",
+            "tool_call_id": "tooluse_5lnW6BJJLFew8zk9eFpVUt",
+        }
+
+    def test_transform_tool_span_choice_multiple_text_blocks(self):
+        """Test that multi-block tool outputs are joined with newlines."""
+        delegate = MagicMock()
+        delegate.export.return_value = 0
+
+        exporter = LangSmithSpanExporter(delegate=delegate)
+
+        choice_event = self._make_event(
+            "gen_ai.choice",
+            {
+                "message": json.dumps(
+                    [
+                        {"text": "Line one"},
+                        {"text": "Line two"},
+                    ]
+                ),
+                "id": "tooluse_abc123",
+            },
+        )
+        span = self._make_span(
+            name="execute_tool my_tool",
+            attributes={
+                "gen_ai.operation.name": "execute_tool",
+                "gen_ai.tool.name": "my_tool",
+                "gen_ai.tool.call.id": "tooluse_abc123",
+            },
+            events=[choice_event],
+        )
+
+        exporter.export([span])
+        transformed = delegate.export.call_args[0][0][0]
+
+        completion = json.loads(transformed.attributes["gen_ai.completion"])
+        assert completion["role"] == "tool"
+        assert completion["content"] == "Line one\nLine two"
+        assert completion["name"] == "my_tool"
+        assert completion["tool_call_id"] == "tooluse_abc123"
+
+    def test_transform_chat_span_choice_still_uses_message_format(self):
+        """Test that chat span choice events still produce
+        message-format completions."""
+        delegate = MagicMock()
+        delegate.export.return_value = 0
+
+        exporter = LangSmithSpanExporter(delegate=delegate)
+
+        choice_event = self._make_event(
+            "gen_ai.choice",
+            {"message": json.dumps([{"text": "Final response"}])},
+        )
+        span = self._make_span(
+            name="chat",
+            attributes={"gen_ai.operation.name": "chat"},
+            events=[choice_event],
+        )
+
+        exporter.export([span])
+        transformed = delegate.export.call_args[0][0][0]
+
+        completion_data = 
json.loads(transformed.attributes["gen_ai.completion"])
+        assert completion_data["role"] == "assistant"
+        assert "content" in completion_data
+
+    def test_extract_tool_output_single_text_block(self):
+        """Test extraction from a single text block."""
+        result = LangSmithSpanExporter._extract_tool_output([{"text": "Hello 
world"}])
+        assert result == "Hello world"
+
+    def test_extract_tool_output_string_passthrough(self):
+        """Test that strings pass through unchanged."""
+        result = LangSmithSpanExporter._extract_tool_output("already a string")
+        assert result == "already a string"
+
+    def test_extract_tool_output_non_text_block(self):
+        """Test that non-text blocks are JSON serialized."""
+        content = [{"image": "base64data", "format": "png"}]
+        result = LangSmithSpanExporter._extract_tool_output(content)
+        assert json.loads(result) == {"image": "base64data", "format": "png"}
+
     def test_flatten_tool_result_message(self):
         """Test flattening of Bedrock tool result messages."""
         content_blocks = [

Reply via email to