diff --git a/.jscpd.json b/.jscpd.json index d46f0552..23080e2f 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -14,6 +14,6 @@ "mode": "weak", "kind": ["exact", "renamed"], "ignoreIdentifiers": true, - "threshold": 16.3, + "threshold": 16.31, "reporters": ["console"] } diff --git a/examples/introspection_examples/reference/python_sdk.py b/examples/introspection_examples/reference/python_sdk.py index e57a661a..865c8e29 100644 --- a/examples/introspection_examples/reference/python_sdk.py +++ b/examples/introspection_examples/reference/python_sdk.py @@ -90,7 +90,9 @@ async def conversations_events_metrics(runner: AsyncRunner, task_id: str) -> Non print(c.id, c.cost.usd) # Tool calls only, e.g. a submit_result tool's arguments (a JSON string) - async for item in runner.conversations.items.list(task_id, operation_name="execute_tool"): + async for item in runner.conversations.items.list( + task_id, contains=["gen_ai.operation.name:execute_tool"] + ): tool = item.attributes.gen_ai.tool if item.attributes.gen_ai else None if tool and tool.call: print(tool.name, tool.call.arguments) diff --git a/introspection_sdk/_async/protocols.py b/introspection_sdk/_async/protocols.py index 79ae774b..f146ef27 100644 --- a/introspection_sdk/_async/protocols.py +++ b/introspection_sdk/_async/protocols.py @@ -258,7 +258,7 @@ def list( include: builtins.list[ConversationItemInclude] | None = None, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + contains: builtins.list[str] | None = None, start_date: str | None = None, end_date: str | None = None, lookback_days: int | None = None, @@ -366,7 +366,7 @@ async def export_trajectory( *, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + operation_name: str | builtins.list[str] | None = None, lookback_days: int | None = None, start_date: str | datetime | None = None, end_date: str | datetime | None = None, @@ -378,7 +378,7 @@ async def export_arrow( *, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + operation_name: str | builtins.list[str] | None = None, lookback_days: int | None = None, start_date: str | datetime | None = None, end_date: str | datetime | None = None, diff --git a/introspection_sdk/_async/runner_resources/conversations.py b/introspection_sdk/_async/runner_resources/conversations.py index 4522a051..6d8a9b36 100644 --- a/introspection_sdk/_async/runner_resources/conversations.py +++ b/introspection_sdk/_async/runner_resources/conversations.py @@ -66,7 +66,7 @@ def build_export_params( *, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + operation_name: str | builtins.list[str] | None = None, lookback_days: int | None = None, start_date: str | datetime | None = None, end_date: str | datetime | None = None, @@ -268,7 +268,7 @@ def list( include: builtins.list[ConversationItemInclude] | None = None, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + contains: builtins.list[str] | None = None, start_date: str | None = None, end_date: str | None = None, lookback_days: int | None = None, @@ -285,6 +285,32 @@ def list( Items carry the turn-local delta in ``input_messages`` — only the messages new to that turn. Use :meth:`get` for the full input history of a span. + + ``contains`` keeps the rows that contain any of the given + ``:`` pairs over the GenAI semconv attributes (at most + 20, sent as the repeated ``contains`` key): + + - ``gen_ai.input.messages.role:user`` + - ``gen_ai.input.messages.parts.type:tool_call_response`` + - ``gen_ai.input.messages.parts.name:`` + - ``gen_ai.output.messages.parts.type:text`` + - ``gen_ai.output.messages.parts.type:tool_call`` + - ``gen_ai.output.messages.parts.name:`` + - ``gen_ai.tool.name:`` + - ``gen_ai.operation.name:`` + + A value on one of the four name paths may end in a single ``*`` to + match by prefix: ``gen_ai.output.messages.parts.name:mcp_browserbase_*`` + or ``gen_ai.tool.name:mcp_*``, and ``gen_ai.input.messages.parts.name:*`` + matches any name. The server rejects ``*`` anywhere else, or on a role + or part-type value, with a 422 + (:class:`~introspection_sdk.ValidationError`). + + Input paths are evaluated over the input messages new to the item, + not the full history ``include=["gen_ai.input.messages"]`` adds. An + item matched only by an input value returns only its new input + messages, one matched only by an output value only its output, and + one matched by an attribute path the whole item. """ # unasync: end """List conversation items using an opaque ``next`` cursor. Iterate @@ -298,6 +324,32 @@ def list( Items carry the turn-local delta in ``input_messages`` — only the messages new to that turn. Use :meth:`get` for the full input history of a span. + + ``contains`` keeps the rows that contain any of the given + ``:`` pairs over the GenAI semconv attributes (at most + 20, sent as the repeated ``contains`` key): + + - ``gen_ai.input.messages.role:user`` + - ``gen_ai.input.messages.parts.type:tool_call_response`` + - ``gen_ai.input.messages.parts.name:`` + - ``gen_ai.output.messages.parts.type:text`` + - ``gen_ai.output.messages.parts.type:tool_call`` + - ``gen_ai.output.messages.parts.name:`` + - ``gen_ai.tool.name:`` + - ``gen_ai.operation.name:`` + + A value on one of the four name paths may end in a single ``*`` to + match by prefix: ``gen_ai.output.messages.parts.name:mcp_browserbase_*`` + or ``gen_ai.tool.name:mcp_*``, and ``gen_ai.input.messages.parts.name:*`` + matches any name. The server rejects ``*`` anywhere else, or on a role + or part-type value, with a 422 + (:class:`~introspection_sdk.ValidationError`). + + Input paths are evaluated over the input messages new to the item, + not the full history ``include=["gen_ai.input.messages"]`` adds. An + item matched only by an input value returns only its new input + messages, one matched only by an output value only its output, and + one matched by an attribute path the whole item. """ async def fetch(cursor: str | None) -> GenAiSpanList: @@ -307,7 +359,7 @@ async def fetch(cursor: str | None) -> GenAiSpanList: "include": include, "agent": agent, "service_name": service_name, - "operation_name": operation_name, + "contains": contains, "start_date": start_date, "end_date": end_date, "lookback_days": lookback_days, @@ -617,7 +669,7 @@ async def export_trajectory( *, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + operation_name: str | builtins.list[str] | None = None, lookback_days: int | None = None, start_date: str | datetime | None = None, end_date: str | datetime | None = None, @@ -655,7 +707,7 @@ async def export_arrow( *, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + operation_name: str | builtins.list[str] | None = None, lookback_days: int | None = None, start_date: str | datetime | None = None, end_date: str | datetime | None = None, diff --git a/introspection_sdk/_sync/protocols.py b/introspection_sdk/_sync/protocols.py index fe81f60b..ada909c0 100644 --- a/introspection_sdk/_sync/protocols.py +++ b/introspection_sdk/_sync/protocols.py @@ -260,7 +260,7 @@ def list( include: builtins.list[ConversationItemInclude] | None = None, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + contains: builtins.list[str] | None = None, start_date: str | None = None, end_date: str | None = None, lookback_days: int | None = None, @@ -368,7 +368,7 @@ def export_trajectory( *, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + operation_name: str | builtins.list[str] | None = None, lookback_days: int | None = None, start_date: str | datetime | None = None, end_date: str | datetime | None = None, @@ -380,7 +380,7 @@ def export_arrow( *, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + operation_name: str | builtins.list[str] | None = None, lookback_days: int | None = None, start_date: str | datetime | None = None, end_date: str | datetime | None = None, diff --git a/introspection_sdk/_sync/runner_resources/conversations.py b/introspection_sdk/_sync/runner_resources/conversations.py index 7fc8453c..c2120203 100644 --- a/introspection_sdk/_sync/runner_resources/conversations.py +++ b/introspection_sdk/_sync/runner_resources/conversations.py @@ -68,7 +68,7 @@ def build_export_params( *, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + operation_name: str | builtins.list[str] | None = None, lookback_days: int | None = None, start_date: str | datetime | None = None, end_date: str | datetime | None = None, @@ -262,7 +262,7 @@ def list( include: builtins.list[ConversationItemInclude] | None = None, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + contains: builtins.list[str] | None = None, start_date: str | None = None, end_date: str | None = None, lookback_days: int | None = None, @@ -278,6 +278,32 @@ def list( Items carry the turn-local delta in ``input_messages`` — only the messages new to that turn. Use :meth:`get` for the full input history of a span. + + ``contains`` keeps the rows that contain any of the given + ``:`` pairs over the GenAI semconv attributes (at most + 20, sent as the repeated ``contains`` key): + + - ``gen_ai.input.messages.role:user`` + - ``gen_ai.input.messages.parts.type:tool_call_response`` + - ``gen_ai.input.messages.parts.name:`` + - ``gen_ai.output.messages.parts.type:text`` + - ``gen_ai.output.messages.parts.type:tool_call`` + - ``gen_ai.output.messages.parts.name:`` + - ``gen_ai.tool.name:`` + - ``gen_ai.operation.name:`` + + A value on one of the four name paths may end in a single ``*`` to + match by prefix: ``gen_ai.output.messages.parts.name:mcp_browserbase_*`` + or ``gen_ai.tool.name:mcp_*``, and ``gen_ai.input.messages.parts.name:*`` + matches any name. The server rejects ``*`` anywhere else, or on a role + or part-type value, with a 422 + (:class:`~introspection_sdk.ValidationError`). + + Input paths are evaluated over the input messages new to the item, + not the full history ``include=["gen_ai.input.messages"]`` adds. An + item matched only by an input value returns only its new input + messages, one matched only by an output value only its output, and + one matched by an attribute path the whole item. """ def fetch(cursor: str | None) -> GenAiSpanList: @@ -287,7 +313,7 @@ def fetch(cursor: str | None) -> GenAiSpanList: "include": include, "agent": agent, "service_name": service_name, - "operation_name": operation_name, + "contains": contains, "start_date": start_date, "end_date": end_date, "lookback_days": lookback_days, @@ -575,7 +601,7 @@ def export_trajectory( *, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + operation_name: str | builtins.list[str] | None = None, lookback_days: int | None = None, start_date: str | datetime | None = None, end_date: str | datetime | None = None, @@ -613,7 +639,7 @@ def export_arrow( *, agent: str | None = None, service_name: str | None = None, - operation_name: str | None = None, + operation_name: str | builtins.list[str] | None = None, lookback_days: int | None = None, start_date: str | datetime | None = None, end_date: str | datetime | None = None, diff --git a/introspection_sdk/atif.py b/introspection_sdk/atif.py index 67b69d2a..4fa5bc5f 100644 --- a/introspection_sdk/atif.py +++ b/introspection_sdk/atif.py @@ -161,10 +161,11 @@ def add(self, span: GenAiSpan) -> list[AtifStep]: ) ) + introspection = span.attributes.introspection + history = introspection.history if introspection else None declared = ( - span.attributes.introspection.conversation.new_messages_start - if span.attributes.introspection - and span.attributes.introspection.conversation + history.new_messages.start + if history and history.new_messages else None ) for message in self._new_messages(lane, inputs, declared): diff --git a/introspection_sdk/runner_resources/_conversations_common.py b/introspection_sdk/runner_resources/_conversations_common.py index ef0cd878..9bb12fa2 100644 --- a/introspection_sdk/runner_resources/_conversations_common.py +++ b/introspection_sdk/runner_resources/_conversations_common.py @@ -11,7 +11,7 @@ class ConversationExportParams(TypedDict, total=False): agent: str service_name: str - operation_name: str + operation_name: str | list[str] lookback_days: int start_date: str | datetime end_date: str | datetime diff --git a/introspection_sdk/schemas/__init__.py b/introspection_sdk/schemas/__init__.py index 30009543..b02ff41b 100644 --- a/introspection_sdk/schemas/__init__.py +++ b/introspection_sdk/schemas/__init__.py @@ -38,8 +38,12 @@ Conversation, ConversationAgent, ConversationCost, + ConversationExperiment, ConversationItemInclude, ConversationMetrics, + ConversationOwner, + ConversationRecipe, + ConversationRuntime, ConversationSortField, ConversationUsage, ) @@ -177,8 +181,12 @@ "Conversation", "ConversationAgent", "ConversationCost", + "ConversationExperiment", "ConversationItemInclude", "ConversationMetrics", + "ConversationOwner", + "ConversationRecipe", + "ConversationRuntime", "ConversationSortField", "ConversationUsage", "Event", diff --git a/introspection_sdk/schemas/conversations.py b/introspection_sdk/schemas/conversations.py index 48f4f693..98898d1d 100644 --- a/introspection_sdk/schemas/conversations.py +++ b/introspection_sdk/schemas/conversations.py @@ -18,8 +18,12 @@ "Conversation", "ConversationAgent", "ConversationCost", + "ConversationExperiment", "ConversationItemInclude", "ConversationMetrics", + "ConversationOwner", + "ConversationRecipe", + "ConversationRuntime", "ConversationSortField", "ConversationUsage", "SpanKind", @@ -36,7 +40,13 @@ class ConversationAgent(OmitNoneModel): class ConversationUsage(OmitNoneModel): - input_tokens: int = 0 + """Token totals across the conversation. + + ``total_input_tokens`` counts uncached input plus cache reads and writes, + so it is not the sum of the items' ``gen_ai.usage.input_tokens``. + """ + + total_input_tokens: int = 0 output_tokens: int = 0 total_tokens: int = 0 @@ -48,12 +58,30 @@ class ConversationCost(OmitNoneModel): class ConversationMetrics(OmitNoneModel): duration_ms: float = 0.0 trace_count: int = 0 + max_turn: int | None = None span_count: int = 0 tool_use_count: int = 0 failed_tool_use_count: int = 0 has_errors: bool = False +class ConversationRuntime(OmitNoneModel): + id: UUID | None = None + group_id: UUID | None = None + + +class ConversationExperiment(OmitNoneModel): + id: UUID | None = None + + +class ConversationRecipe(OmitNoneModel): + git_commit_sha: str | None = None + + +class ConversationOwner(OmitNoneModel): + key: str | None = None + + class Conversation(OmitNoneModel): object: Literal["conversation"] = "conversation" id: str @@ -66,11 +94,10 @@ class Conversation(OmitNoneModel): metrics: ConversationMetrics = Field(default_factory=ConversationMetrics) environment: str | None = None service_name: str | None = None - runtime_id: UUID | None = None - runtime_group_id: UUID | None = None - experiment_id: UUID | None = None - recipe_git_commit_sha: str | None = None - owner_key: str | None = None + runtime: ConversationRuntime | None = None + experiment: ConversationExperiment | None = None + recipe: ConversationRecipe | None = None + owner: ConversationOwner | None = None metadata: dict[str, str] | None = None diff --git a/introspection_sdk/schemas/genai_span.py b/introspection_sdk/schemas/genai_span.py index d834d2cf..ec6aafe9 100644 --- a/introspection_sdk/schemas/genai_span.py +++ b/introspection_sdk/schemas/genai_span.py @@ -56,7 +56,9 @@ "GenAiCost", "GenAiUsage", "IntrospectionAttributes", - "IntrospectionConversation", + "IntrospectionContains", + "IntrospectionHistory", + "IntrospectionNewMessages", "IntrospectionRecipe", "IntrospectionRuntime", "SpanAttributes", @@ -277,24 +279,44 @@ class IntrospectionRecipe(_SpanModel): git_commit_sha: str | None = None -class IntrospectionConversation(_SpanModel): - """``introspection.conversation.*``. +class IntrospectionNewMessages(_SpanModel): + """``introspection.history.new_messages.*``: the half-open range of this + call's input messages that were new to the conversation.""" - These describe the turn's place in the conversation. + start: int | None = None + end: int | None = None + + +class IntrospectionHistory(_SpanModel): + """``introspection.history.*``: where this model call sits in the + conversation's message history, as the processor's linker inferred it. + + Absent on spans the linker never placed, such as tool and API calls. + ``position`` is 1-based. """ position: int | None = None is_new: bool | None = None continuation_method: str | None = None - history_hash_hit: bool | None = None - new_messages_start: int | None = None - new_messages_end: int | None = None - client_message_id: str | None = None - trace_count: int | None = None - span_count: int | None = None - tool_use_count: int | None = None - failed_tool_use_count: int | None = None - has_errors: bool | None = None + hash_hit: bool | None = None + new_messages: IntrospectionNewMessages | None = None + + +class IntrospectionContains(_SpanModel): + """``introspection.contains.*``: what a chat span's sealed input delta and + output contain. + + Stamped at ingest so a read can select rows without opening them. + ``values`` are the ``contains=`` filter values the span answers, spelled + exactly as a client passes them to ``items.list(contains=...)``, such as + ``"gen_ai.input.messages.role:user"`` or + ``"gen_ai.output.messages.parts.name:"``. A name list cut at the + processor's bound carries ``":*"``. Paths and names only, never + content. ``version`` is 0 or absent on a span that was never summarized. + """ + + version: int | None = None + values: list[str] | None = None class IntrospectionAttributes(_SpanModel): @@ -311,8 +333,10 @@ class IntrospectionAttributes(_SpanModel): runtime: IntrospectionRuntime | None = None experiment: _Id | None = None environment: str | None = None - conversation: IntrospectionConversation | None = None + turn: int | None = None + history: IntrospectionHistory | None = None recipe: IntrospectionRecipe | None = None + contains: IntrospectionContains | None = None # --- the span ------------------------------------------------------------- diff --git a/tests/rest/test_conversations.py b/tests/rest/test_conversations.py index da4c1340..2d5feac5 100644 --- a/tests/rest/test_conversations.py +++ b/tests/rest/test_conversations.py @@ -11,6 +11,7 @@ from __future__ import annotations +import inspect import io from typing import Any from uuid import UUID @@ -19,6 +20,7 @@ import pyarrow as pa import pytest +from introspection_sdk import ValidationError from introspection_sdk.runner_resources import ( AsyncConversations, Conversations, @@ -26,6 +28,8 @@ from introspection_sdk.runner_resources._reads import ARROW_STREAM_MEDIA_TYPE from introspection_sdk.runner_resources.conversations import ( LATEST_TURN_SCAN_LIMIT, + AsyncConversationItems, + ConversationItems, ) from introspection_sdk.schemas.conversations import Conversation from introspection_sdk.schemas.genai import ( @@ -54,7 +58,7 @@ "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:05Z", "usage": { - "input_tokens": 10, + "total_input_tokens": 10, "output_tokens": 20, "total_tokens": 30, }, @@ -62,6 +66,7 @@ "metrics": { "duration_ms": 5000, "trace_count": 1, + "max_turn": 2, "span_count": 3, "tool_use_count": 2, "failed_tool_use_count": 1, @@ -69,9 +74,10 @@ }, "environment": "production", "service_name": "agent-runtime", - "runtime_id": RUNTIME_ID, - "experiment_id": EXPERIMENT_ID, - "recipe_git_commit_sha": "abc123", + "runtime": {"id": RUNTIME_ID, "group_id": RUNTIME_GROUP_ID}, + "experiment": {"id": EXPERIMENT_ID}, + "recipe": {"git_commit_sha": "abc123"}, + "owner": {"key": "user:u-1"}, "metadata": {"flow": "checkout", "tenant": "acme"}, } @@ -242,10 +248,41 @@ def test_list_calls_conversations_with_filters(fake_api: FakeAPI): assert summary.id == "conv-1" assert summary.task_title == "Map Australian fintech leaders" assert summary.usage.total_tokens == 30 + assert summary.usage.total_input_tokens == 10 assert summary.cost.usd == 0.01 + assert summary.metrics.max_turn == 2 assert summary.metrics.tool_use_count == 2 assert summary.metrics.failed_tool_use_count == 1 assert summary.metadata == {"flow": "checkout", "tenant": "acme"} + assert summary.runtime is not None + assert summary.runtime.id == UUID(RUNTIME_ID) + assert summary.runtime.group_id == UUID(RUNTIME_GROUP_ID) + assert summary.experiment is not None + assert summary.experiment.id == UUID(EXPERIMENT_ID) + assert summary.recipe is not None + assert summary.recipe.git_commit_sha == "abc123" + assert summary.owner is not None + assert summary.owner.key == "user:u-1" + + +def test_conversation_omits_identity_groups_it_lacks(): + fixture = { + key: value + for key, value in SUMMARY_FIXTURE.items() + if key not in {"runtime", "experiment", "recipe", "owner"} + } + fixture["metrics"] = { + k: v for k, v in fixture["metrics"].items() if k != "max_turn" + } + summary = Conversation.model_validate(fixture) + assert summary.runtime is None + assert summary.experiment is None + assert summary.recipe is None + assert summary.owner is None + assert summary.metrics.max_turn is None + dumped = summary.model_dump() + for group in ("runtime", "experiment", "recipe", "owner"): + assert group not in dumped def test_list_omits_empty_metadata_filter(fake_api: FakeAPI): @@ -528,6 +565,185 @@ def test_items_list_passes_includes(fake_api: FakeAPI): assert req.params.get("agent_id") is None +def test_items_list_repeats_contains_values(fake_api: FakeAPI): + fake_api.add( + "GET", + "/v1/conversations/conv-1/items", + json_body=make_page([make_item()], False), + ) + + _conversations(fake_api).items.list( + "conv-1", + contains=[ + "gen_ai.input.messages.role:user", + "gen_ai.tool.name:message_user", + "gen_ai.input.messages.parts.name:react", + ], + ).page() + + # The filter repeats the key, any value of which matches server-side. + assert fake_api.last_request.params.get_list("contains") == [ + "gen_ai.input.messages.role:user", + "gen_ai.tool.name:message_user", + "gen_ai.input.messages.parts.name:react", + ] + + +def test_items_list_sends_name_prefix_wildcards_verbatim(fake_api: FakeAPI): + """A trailing `*` on a name path is a prefix match the server resolves; + the SDK sends the value unchanged and the matching rows come back.""" + fake_api.add( + "GET", + "/v1/conversations/conv-1/items", + json_body=make_page( + [ + make_item( + attributes={ + "introspection": { + "contains": { + "version": 1, + "values": [ + "gen_ai.output.messages.parts.type:tool_call", + "gen_ai.output.messages.parts.name:" + "mcp_browserbase_navigate", + ], + } + } + } + ) + ], + False, + ), + ) + + page = ( + _conversations(fake_api) + .items.list( + "conv-1", + contains=[ + "gen_ai.output.messages.parts.name:mcp_browserbase_*", + "gen_ai.tool.name:mcp_*", + "gen_ai.input.messages.parts.name:*", + ], + ) + .page() + ) + + assert fake_api.last_request.params.get_list("contains") == [ + "gen_ai.output.messages.parts.name:mcp_browserbase_*", + "gen_ai.tool.name:mcp_*", + "gen_ai.input.messages.parts.name:*", + ] + contains = present(present(page.data[0].attributes.introspection).contains) + assert "gen_ai.output.messages.parts.name:mcp_browserbase_navigate" in ( + present(contains.values) + ) + + +def test_items_list_surfaces_a_rejected_wildcard(fake_api: FakeAPI): + """`*` is a prefix match on a name path only; the server rejects it on a + role or part type with a 422, raised as `ValidationError`.""" + fake_api.add( + "GET", + "/v1/conversations/conv-1/items", + status=422, + json_body={ + "detail": "contains: '*' is accepted only at the end of a name value" + }, + ) + + with pytest.raises(ValidationError) as exc: + _conversations(fake_api).items.list( + "conv-1", contains=["gen_ai.input.messages.role:*"] + ).page() + + assert exc.value.status_code == 422 + assert fake_api.last_request.params.get_list("contains") == [ + "gen_ai.input.messages.role:*" + ] + + +def test_items_list_filters_operations_through_contains(fake_api: FakeAPI): + """`items.list` takes no `operation_name`: an operation filter is a + `gen_ai.operation.name:` value of `contains`, and only that key goes + out.""" + fake_api.add( + "GET", + "/v1/conversations/conv-1/items", + json_body=make_page([make_item()], False), + ) + + _conversations(fake_api).items.list( + "conv-1", + contains=[ + "gen_ai.operation.name:chat", + "gen_ai.operation.name:execute_tool", + ], + ).page() + + params = fake_api.last_request.params + assert params.get_list("contains") == [ + "gen_ai.operation.name:chat", + "gen_ai.operation.name:execute_tool", + ] + assert "operation_name" not in params + + +def test_items_list_rejects_operation_name(): + """The removed keyword is a TypeError, not a silently ignored filter.""" + assert ( + "operation_name" + not in inspect.signature(ConversationItems.list).parameters + ) + assert ( + "operation_name" + not in inspect.signature(AsyncConversationItems.list).parameters + ) + + +def test_items_carry_the_contains_summary_as_typed_attributes( + fake_api: FakeAPI, +): + fake_api.add( + "GET", + "/v1/conversations/conv-1/items", + json_body=make_page( + [ + make_item( + attributes={ + "introspection": { + "contains": { + "version": 1, + "values": [ + "gen_ai.input.messages.role:user", + "gen_ai.input.messages.parts.name:react", + "gen_ai.output.messages.parts.type:tool_call", + "gen_ai.output.messages.parts.name:message_user", + "gen_ai.tool.name:*", + ], + } + } + } + ) + ], + False, + ), + ) + + page = _conversations(fake_api).items.list("conv-1") + + contains = present(present(page.data[0].attributes.introspection).contains) + assert contains.version == 1 + assert contains.values == [ + "gen_ai.input.messages.role:user", + "gen_ai.input.messages.parts.name:react", + "gen_ai.output.messages.parts.type:tool_call", + "gen_ai.output.messages.parts.name:message_user", + # A name list cut at the processor's bound carries `:*`. + "gen_ai.tool.name:*", + ] + + def test_items_iter_drives_next_cursor_while_has_more(fake_api: FakeAPI): fake_api.add_handler( "GET", @@ -997,6 +1213,21 @@ def test_export_stream_preserves_wire_bytes_and_filters( } +def test_export_repeats_operation_names(fake_api: FakeAPI): + """The export takes the repeated `operation_name` and no `content`.""" + fake_api.add( + "GET", "/v1/conversations/conv-1/export", json_body=TRAJECTORY_BODY + ) + + _conversations(fake_api).export_trajectory( + "conv-1", operation_name=["chat", "execute_tool"] + ) + + params = fake_api.requests[-1].params + assert params.get_list("operation_name") == ["chat", "execute_tool"] + assert "content" not in params + + def test_export_encodes_conversation_id(fake_api: FakeAPI): fake_api.add("GET", "/v1/conversations/conv/one/export", content=b"export") diff --git a/tests/schemas/test_genai_span.py b/tests/schemas/test_genai_span.py index 08d6cd2d..05da20f4 100644 --- a/tests/schemas/test_genai_span.py +++ b/tests/schemas/test_genai_span.py @@ -96,7 +96,14 @@ def _nulls(value: Any, path: str = "") -> list[str]: "introspection": { "member": {"id": "019fbe0c"}, "environment": "production", - "conversation": {"position": 1, "is_new": True}, + "turn": 2, + "history": { + "position": 1, + "is_new": True, + "continuation_method": "history", + "hash_hit": True, + "new_messages": {"start": 0, "end": 1}, + }, }, }, } @@ -135,6 +142,37 @@ def test_introspection_attributes_sit_beside_gen_ai_not_inside_it( assert present(introspection.member).id == "019fbe0c" assert introspection.environment == "production" + def test_history_groups_the_linker_inference_beside_the_turn( + self, + ) -> None: + introspection = present( + GenAiSpan.model_validate(FULL_SPAN).attributes.introspection + ) + + assert introspection.turn == 2 + history = present(introspection.history) + assert history.position == 1 + assert history.is_new is True + assert history.continuation_method == "history" + assert history.hash_hit is True + new_messages = present(history.new_messages) + assert (new_messages.start, new_messages.end) == (0, 1) + assert not history.model_extra + + def test_a_span_the_linker_never_placed_has_no_history(self) -> None: + span = GenAiSpan.model_validate( + { + "trace_id": "t", + "start_time": "2026-08-04T22:14:34Z", + "name": "execute_tool search", + "attributes": {"introspection": {"turn": 3}}, + } + ) + + introspection = present(span.attributes.introspection) + assert introspection.turn == 3 + assert introspection.history is None + class TestNullIsNeverSerialized: def test_a_fully_populated_span_serializes_no_nulls(self) -> None: @@ -357,33 +395,6 @@ def test_provider_reported_cost_is_a_separate_measurement(self) -> None: introspection = present(span.attributes.introspection) assert (introspection.model_extra or {})["llm"] == {"cost_usd": 0.0091} - def test_conversation_rollups_stay_conversation_scoped(self) -> None: - # The counts have no semantic-convention name and genuinely describe the - # conversation, so they nest under `introspection.conversation`. Cost - # has a name the span already uses, so it does not move here. - span = GenAiSpan.model_validate( - { - "trace_id": "t", - "start_time": "2026-08-04T22:14:34Z", - "attributes": { - "gen_ai": {"cost": {"usd": 0.0098}}, - "introspection": { - "conversation": { - "trace_count": 3, - "tool_use_count": 4, - }, - }, - }, - } - ) - - conversation = present( - present(span.attributes.introspection).conversation - ) - assert present(present(span.attributes.gen_ai).cost).usd == 0.0098 - assert conversation.trace_count == 3 - assert conversation.tool_use_count == 4 - class TestNestedIdentityNodes: """Nodes the server emits with more than one field are typed for all of them. diff --git a/tests/test_atif.py b/tests/test_atif.py index 20459fed..ebafc916 100644 --- a/tests/test_atif.py +++ b/tests/test_atif.py @@ -88,7 +88,7 @@ def chat( attributes: dict[str, Any] = {"gen_ai": gen_ai} if new_messages_start is not None: attributes["introspection"] = { - "conversation": {"new_messages_start": new_messages_start} + "history": {"new_messages": {"start": new_messages_start}} } return GenAiSpan.model_validate( {