diff --git a/.bumpversion.toml b/.bumpversion.toml index 78cd8d06..3af8812f 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -3,7 +3,7 @@ # https://peps.python.org/pep-0440/ [tool.bumpversion] - current_version = "0.4.1.dev5" + current_version = "0.4.2" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2810858e..d3c2d0df 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,14 +16,14 @@ repos: - id: check-toml - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.14 + rev: v0.15.11 hooks: - id: ruff-check args: [--fix] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.19.1 + rev: v1.20.2 hooks: - id: mypy additional_dependencies: [types-protobuf] diff --git a/pyproject.toml b/pyproject.toml index 54eff643..eb9c40a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ "grpcio-status==1.78.0", "pydantic==2.12.5", ] - version = "0.4.1.dev5" + version = "0.4.2" [project.optional-dependencies] profiling = [ @@ -64,10 +64,9 @@ "build==1.4.2", "bump-my-version==1.2.7", "cryptography==46.0.6", - "mypy==1.19.1", + "mypy==1.20.2", "pre-commit==4.5.1", - "pyright==1.1.408", - "ruff==0.15.8", + "ruff==0.15.11", "twine==6.2.0", "types-grpcio-health-checking==1.0.0.20250506", "types-grpcio-reflection==1.0.0.20250506", @@ -266,10 +265,6 @@ quote-style = "double" skip-magic-trailing-comma = false -[tool.pyright] - exclude = [ "**/.*", "**/.venv", "**/__pycache__", "**/node_modules" ] - include = [ "src" ] - [tool.mypy] exclude = [ "examples", "tests" ] diff --git a/src/digitalkin/__version__.py b/src/digitalkin/__version__.py index 02b21d5f..1d93645d 100644 --- a/src/digitalkin/__version__.py +++ b/src/digitalkin/__version__.py @@ -5,4 +5,4 @@ try: __version__ = version("digitalkin") except PackageNotFoundError: - __version__ = "0.4.1.dev5" + __version__ = "0.4.2" diff --git a/src/digitalkin/community/agno/agno_adapter.py b/src/digitalkin/community/agno/agno_adapter.py index 5bd4d22a..3dbcb17b 100644 --- a/src/digitalkin/community/agno/agno_adapter.py +++ b/src/digitalkin/community/agno/agno_adapter.py @@ -12,11 +12,104 @@ import logging import uuid -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeAlias if TYPE_CHECKING: from collections.abc import Callable + from agno.run.agent import ( + BaseAgentRunEvent as _AgentBase, + ) + from agno.run.agent import ( + ReasoningCompletedEvent as _AgentReasoningCompleted, + ) + from agno.run.agent import ( + ReasoningContentDeltaEvent as _AgentReasoningContentDelta, + ) + from agno.run.agent import ( + ReasoningStartedEvent as _AgentReasoningStarted, + ) + from agno.run.agent import ( + ReasoningStepEvent as _AgentReasoningStep, + ) + from agno.run.agent import ( + RunCompletedEvent as _AgentRunCompleted, + ) + from agno.run.agent import ( + RunContentEvent as _AgentRunContent, + ) + from agno.run.agent import ( + RunErrorEvent as _AgentRunError, + ) + from agno.run.agent import ( + RunPausedEvent as _AgentRunPaused, + ) + from agno.run.agent import ( + RunStartedEvent as _AgentRunStarted, + ) + from agno.run.agent import ( + ToolCallCompletedEvent as _AgentToolCallCompleted, + ) + from agno.run.agent import ( + ToolCallErrorEvent as _AgentToolCallError, + ) + from agno.run.agent import ( + ToolCallStartedEvent as _AgentToolCallStarted, + ) + from agno.run.team import ( + BaseTeamRunEvent as _TeamBase, + ) + from agno.run.team import ( + ReasoningCompletedEvent as _TeamReasoningCompleted, + ) + from agno.run.team import ( + ReasoningContentDeltaEvent as _TeamReasoningContentDelta, + ) + from agno.run.team import ( + ReasoningStartedEvent as _TeamReasoningStarted, + ) + from agno.run.team import ( + ReasoningStepEvent as _TeamReasoningStep, + ) + from agno.run.team import ( + RunCompletedEvent as _TeamRunCompleted, + ) + from agno.run.team import ( + RunContentEvent as _TeamRunContent, + ) + from agno.run.team import ( + RunErrorEvent as _TeamRunError, + ) + from agno.run.team import ( + RunPausedEvent as _TeamRunPaused, + ) + from agno.run.team import ( + RunStartedEvent as _TeamRunStarted, + ) + from agno.run.team import ( + ToolCallCompletedEvent as _TeamToolCallCompleted, + ) + from agno.run.team import ( + ToolCallErrorEvent as _TeamToolCallError, + ) + from agno.run.team import ( + ToolCallStartedEvent as _TeamToolCallStarted, + ) + + AgnoRunEvent: TypeAlias = _AgentBase | _TeamBase + AgnoRunStartedEvent: TypeAlias = _AgentRunStarted | _TeamRunStarted + AgnoRunContentEvent: TypeAlias = _AgentRunContent | _TeamRunContent + AgnoRunCompletedEvent: TypeAlias = _AgentRunCompleted | _TeamRunCompleted + AgnoRunErrorEvent: TypeAlias = _AgentRunError | _TeamRunError + AgnoRunPausedEvent: TypeAlias = _AgentRunPaused | _TeamRunPaused + AgnoReasoningStartedEvent: TypeAlias = _AgentReasoningStarted | _TeamReasoningStarted + AgnoReasoningContentDeltaEvent: TypeAlias = _AgentReasoningContentDelta | _TeamReasoningContentDelta + AgnoReasoningStepEvent: TypeAlias = _AgentReasoningStep | _TeamReasoningStep + AgnoReasoningCompletedEvent: TypeAlias = _AgentReasoningCompleted | _TeamReasoningCompleted + AgnoToolCallStartedEvent: TypeAlias = _AgentToolCallStarted | _TeamToolCallStarted + AgnoToolCallCompletedEvent: TypeAlias = _AgentToolCallCompleted | _TeamToolCallCompleted + AgnoToolCallErrorEvent: TypeAlias = _AgentToolCallError | _TeamToolCallError + from digitalkin.models.events import ( AgentRunEvent, BaseAgentRunEvent, @@ -76,7 +169,10 @@ def __init__(self) -> None: self._paused_tool_executions: list[Any] = [] self._paused_requirements: list[Any] = [] - self._dispatch: dict[Any, Callable[[Any, Any], list[BaseAgentRunEvent]]] | None = None + self._dispatch: dict[Any, Callable[..., list[BaseAgentRunEvent]]] | None = None + self._team_enum: type | None = None + + self._last_metadata: dict[str, Any] | None = None @property def is_paused(self) -> bool: @@ -93,7 +189,84 @@ def paused_requirements(self) -> list[Any]: """Agno ``RunRequirement`` objects carried by the paused run.""" return list(self._paused_requirements) - def to_digitalkin_events(self, agno_event: Any) -> list[BaseAgentRunEvent]: + @staticmethod + def _build_metadata(agno_event: AgnoRunEvent, *, is_team: bool) -> dict[str, Any]: + """Extract identity info from a raw Agno event. + + Team leader events carry ``team_id``/``team_name``; agent events carry + ``agent_id``/``agent_name``. Member events set ``parent_run_id`` to the + team's run id so the client can group deltas by speaker. + + ``run_id`` is intentionally absent: it is already carried by the typed + event fields (``RunStartedEvent.run_id`` etc.) and must not be + duplicated in ``metadata``. + + Args: + agno_event: Raw Agno event (Pydantic model or equivalent). + is_team: Whether the event originates from a team-level context. + + Returns: + Dict with ``source``, ``name``, ``id`` and ``parent_run_id`` — + ready to hand to the ``metadata`` field of a DigitalKin event. + """ + data = agno_event.__dict__ + if is_team: + return { + "source": "team", + "name": data.get("team_name"), + "id": data.get("team_id"), + "parent_run_id": data.get("parent_run_id"), + } + return { + "source": "agent", + "name": data.get("agent_name"), + "id": data.get("agent_id"), + "parent_run_id": data.get("parent_run_id"), + } + + def _build_dispatch(self) -> dict[Any, Callable[..., list[BaseAgentRunEvent]]]: + """Import Agno enums lazily and populate the dispatch table. + + Also caches ``TeamRunEvent`` in ``self._team_enum`` for source detection. + + Returns: + Dispatch table mapping Agno event enum members to handlers. + + Raises: + ImportError: If the optional 'agno' dependency is not installed. + """ + try: + from agno.run.agent import RunEvent # pylint: disable=C0415 + from agno.run.team import TeamRunEvent # pylint: disable=C0415 + except ImportError as exc: + message = "The 'agno' package is required to use AgnoStreamAdapter. Install it with: pip install agno" + raise ImportError(message) from exc + + self._team_enum = TeamRunEvent + + handler_by_name: dict[str, Callable[..., list[BaseAgentRunEvent]]] = { + "run_started": self._handle_run_started, + "run_content": self._handle_run_content, + "run_completed": self._handle_run_completed, + "run_error": self._handle_run_error, + "run_paused": self._handle_run_paused, + "reasoning_started": self._handle_reasoning_started, + "reasoning_content_delta": self._handle_reasoning_content_delta, + "reasoning_step": self._handle_reasoning_step, + "reasoning_completed": self._handle_reasoning_completed, + "tool_call_started": self._handle_tool_call_started, + "tool_call_completed": self._handle_tool_call_completed, + "tool_call_error": self._handle_tool_call_error, + } + dispatch = { + enum_cls[name]: handler + for enum_cls in (RunEvent, TeamRunEvent) + for name, handler in handler_by_name.items() + } + self._dispatch = dispatch + return dispatch + + def to_digitalkin_events(self, agno_event: AgnoRunEvent) -> list[BaseAgentRunEvent]: """Convert one Agno event into one or more DigitalKin events. Args: @@ -105,76 +278,124 @@ def to_digitalkin_events(self, agno_event: Any) -> list[BaseAgentRunEvent]: Raises: ImportError: If the optional 'agno' dependency is not installed. """ - if self._dispatch is None: - try: - from agno.run.agent import RunEvent # pylint: disable=C0415 # pyright: ignore[reportMissingImports] - except ImportError as exc: - message = "The 'agno' package is required to use AgnoStreamAdapter. Install it with: pip install agno" - raise ImportError(message) from exc - - self._dispatch = { - RunEvent.run_started: self._handle_run_started, - RunEvent.run_content: self._handle_run_content, - RunEvent.run_completed: self._handle_run_completed, - RunEvent.run_error: self._handle_run_error, - RunEvent.run_paused: self._handle_run_paused, - RunEvent.reasoning_started: self._handle_reasoning_started, - RunEvent.reasoning_content_delta: self._handle_reasoning_content_delta, - RunEvent.reasoning_step: self._handle_reasoning_step, - RunEvent.reasoning_completed: self._handle_reasoning_completed, - RunEvent.tool_call_started: self._handle_tool_call_started, - RunEvent.tool_call_completed: self._handle_tool_call_completed, - RunEvent.tool_call_error: self._handle_tool_call_error, - } + dispatch = self._dispatch if self._dispatch is not None else self._build_dispatch() event_type = agno_event.event logger.debug("Converting Agno event: %s", event_type) - handler = self._dispatch.get(event_type) + handler = dispatch.get(event_type) if handler is None: logger.debug("Skipping unhandled Agno event type: %s", event_type) return [] - return handler(agno_event, getattr(agno_event, "timestamp", None)) + is_team = self._team_enum is not None and isinstance(event_type, self._team_enum) + self._last_metadata = self._build_metadata(agno_event, is_team=is_team) + + return handler(agno_event, agno_event.__dict__.get("timestamp")) # ── Run Lifecycle Handlers ─────────────────────────────────────────── - def _handle_run_started(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_run_started(self, agno_event: AgnoRunStartedEvent, timestamp: Any) -> list[BaseAgentRunEvent]: """Handle RunEvent.run_started. + Nested runs (a team member's own run, or a team invoked from a + workflow) carry a non-empty ``parent_run_id``. The AG-UI protocol + only accepts a single ``RUN_STARTED`` per stream, so we drop + nested ones — content/tool events from members still propagate + and carry ``metadata.parent_run_id`` for client-side routing. + Returns: - List containing a RunStartedEvent, or empty for duplicates. + List containing a RunStartedEvent, or empty for duplicates / nested runs. """ - run_id = getattr(agno_event, "run_id", None) + parent_run_id = getattr(agno_event, "parent_run_id", None) + run_id = agno_event.run_id + + if parent_run_id: + logger.info( + "[agno-adapter] DROP nested run_started run_id=%s parent_run_id=%s agent=%s/%s", + run_id, + parent_run_id, + getattr(agno_event, "agent_id", None), + getattr(agno_event, "agent_name", None), + ) + return [] if run_id and run_id == self._active_run_id: - logger.debug("Skipping duplicate RunStarted for run_id=%s", run_id) + logger.info("[agno-adapter] DROP duplicate run_started run_id=%s", run_id) return [] + logger.info( + "[agno-adapter] EMIT run_started run_id=%s session_id=%s active_was=%s metadata=%s", + run_id, + getattr(agno_event, "session_id", None), + self._active_run_id, + self._last_metadata, + ) self._active_run_id = run_id return [ RunStartedEvent( event=AgentRunEvent.RUN_STARTED, run_id=run_id, - thread_id=getattr(agno_event, "thread_id", None), + thread_id=agno_event.session_id, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ] - def _handle_run_completed(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_run_completed(self, agno_event: AgnoRunCompletedEvent, timestamp: Any) -> list[BaseAgentRunEvent]: """Handle RunEvent.run_completed. + Mirrors ``_handle_run_started``: nested runs are silently dropped + so the outer run's ``RUN_COMPLETED`` stays the single top-level + closure on the stream. + Returns: - List of closing events followed by a RunCompletedEvent. + List of closing events followed by a RunCompletedEvent, + or empty for nested / duplicate events. """ - run_id = getattr(agno_event, "run_id", None) + parent_run_id = getattr(agno_event, "parent_run_id", None) + run_id = agno_event.run_id + + if parent_run_id: + # Close the subagent's text/reasoning bubble so the main agent's + # continuation gets a fresh message_id. Inject a "\n---\n" footer + # on the same message before the TextMessageCompletedEvent so the + # frontend can visually separate subagent content from the rest. + events: list[BaseAgentRunEvent] = [] + if self._content_active: + events.append( + RunContentEvent( + event=AgentRunEvent.RUN_CONTENT, + content=" \n\n --- \n\n ", + message_id=self._current_message_id, + reasoning_content=None, + content_type=None, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + events.extend(self._close_content(timestamp)) + if self._reasoning_active: + events.extend(self._close_reasoning(timestamp)) + logger.info( + "[agno-adapter] DROP nested run_completed run_id=%s parent_run_id=%s closed=%d", + run_id, + parent_run_id, + len(events), + ) + return events if run_id and run_id in self._completed_run_ids and run_id != self._active_run_id: - logger.debug("Skipping duplicate RunCompleted for run_id=%s", run_id) + logger.info("[agno-adapter] DROP duplicate run_completed run_id=%s", run_id) return [] - events: list[BaseAgentRunEvent] = [] + logger.info( + "[agno-adapter] EMIT run_completed run_id=%s active_run_id=%s", + run_id, + self._active_run_id, + ) + + events = [] if self._content_active: events.extend(self._close_content(timestamp)) @@ -185,43 +406,43 @@ def _handle_run_completed(self, agno_event: Any, timestamp: Any) -> list[BaseAge self._completed_run_ids.add(run_id) self._active_run_id = None + content = agno_event.content events.append( RunCompletedEvent( event=AgentRunEvent.RUN_COMPLETED, run_id=run_id, - final_content=str(agno_event.content) if getattr(agno_event, "content", None) else None, + final_content=str(content) if content else None, usage=None, message_id=None, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ) return events - def _handle_run_error( # noqa: PLR6301 - self, - agno_event: Any, - timestamp: Any, - ) -> list[BaseAgentRunEvent]: + def _handle_run_error(self, agno_event: AgnoRunErrorEvent, timestamp: Any) -> list[BaseAgentRunEvent]: """Handle RunEvent.run_error. Returns: List containing a RunErrorEvent. """ + content = agno_event.content return [ RunErrorEvent( event=AgentRunEvent.RUN_ERROR, - error_type=getattr(agno_event, "error_type", None), - content=str(agno_event.content) if getattr(agno_event, "content", None) else None, + error_type=agno_event.error_type, + content=str(content) if content else None, error_details=None, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ] # ── Reasoning Handlers (native Agno reasoning models) ─────────────── - def _handle_reasoning_started(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_reasoning_started( + self, agno_event: AgnoReasoningStartedEvent, timestamp: Any + ) -> list[BaseAgentRunEvent]: """Handle RunEvent.reasoning_started. Returns: @@ -241,12 +462,14 @@ def _handle_reasoning_started(self, agno_event: Any, timestamp: Any) -> list[Bas event=AgentRunEvent.REASONING_STARTED, reasoning_id=self._current_reasoning_id, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ) return events - def _handle_reasoning_content_delta(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_reasoning_content_delta( + self, agno_event: AgnoReasoningContentDeltaEvent, timestamp: Any + ) -> list[BaseAgentRunEvent]: """Handle RunEvent.reasoning_content_delta. Returns: @@ -255,14 +478,14 @@ def _handle_reasoning_content_delta(self, agno_event: Any, timestamp: Any) -> li return [ ReasoningContentDeltaEvent( event=AgentRunEvent.REASONING_CONTENT_DELTA, - delta=getattr(agno_event, "reasoning_content", ""), + delta=agno_event.reasoning_content or "", reasoning_id=self._current_reasoning_id, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ] - def _handle_reasoning_step(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_reasoning_step(self, agno_event: AgnoReasoningStepEvent, timestamp: Any) -> list[BaseAgentRunEvent]: """Handle ``RunEvent.reasoning_step`` — emitted by Agno's ``ReasoningTools``. Unlike the native reasoning events (``reasoning_started`` / @@ -313,12 +536,14 @@ def _handle_reasoning_step(self, agno_event: Any, timestamp: Any) -> list[BaseAg delta=content, reasoning_id=self._current_reasoning_id, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ) return events - def _handle_reasoning_completed(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_reasoning_completed( + self, agno_event: AgnoReasoningCompletedEvent, timestamp: Any + ) -> list[BaseAgentRunEvent]: """Handle RunEvent.reasoning_completed. Returns: @@ -330,7 +555,9 @@ def _handle_reasoning_completed(self, agno_event: Any, timestamp: Any) -> list[B # ── Tool Call Handlers ────────────────────────────────────────────── - def _handle_tool_call_started(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_tool_call_started( + self, agno_event: AgnoToolCallStartedEvent, timestamp: Any + ) -> list[BaseAgentRunEvent]: """Handle RunEvent.tool_call_started. Returns: @@ -344,13 +571,13 @@ def _handle_tool_call_started(self, agno_event: Any, timestamp: Any) -> list[Bas if self._content_active: events.extend(self._close_content(timestamp)) - tool = getattr(agno_event, "tool", None) + tool = agno_event.tool tool_info = None if tool: tool_info = ToolInfo( - tool_call_id=getattr(tool, "tool_call_id", None), - tool_name=getattr(tool, "tool_name", None), - tool_args=getattr(tool, "tool_args", None), + tool_call_id=tool.tool_call_id, + tool_name=tool.tool_name, + tool_args=tool.tool_args, result=None, ) events.append( @@ -358,43 +585,46 @@ def _handle_tool_call_started(self, agno_event: Any, timestamp: Any) -> list[Bas event=AgentRunEvent.TOOL_CALL_STARTED, tool=tool_info, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ) return events - def _handle_tool_call_completed(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_tool_call_completed( + self, agno_event: AgnoToolCallCompletedEvent, timestamp: Any + ) -> list[BaseAgentRunEvent]: """Handle RunEvent.tool_call_completed. Returns: List containing a ToolCallCompletedEvent. """ - tool = getattr(agno_event, "tool", None) + tool = agno_event.tool tool_info = None tool_call_id = None if tool: - tool_call_id = getattr(tool, "tool_call_id", None) + tool_call_id = tool.tool_call_id tool_info = ToolInfo( tool_call_id=tool_call_id, - tool_name=getattr(tool, "tool_name", None), - tool_args=getattr(tool, "tool_args", None), - result=getattr(tool, "result", None), + tool_name=tool.tool_name, + tool_args=tool.tool_args, + result=tool.result, ) if tool_call_id: self._closed_tool_call_ids.add(tool_call_id) + content = agno_event.content return [ ToolCallCompletedEvent( event=AgentRunEvent.TOOL_CALL_COMPLETED, tool=tool_info, - content=str(agno_event.content) if getattr(agno_event, "content", None) else None, + content=str(content) if content else None, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ] - def _handle_run_paused(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_run_paused(self, agno_event: AgnoRunPausedEvent, timestamp: Any) -> list[BaseAgentRunEvent]: """Handle ``RunEvent.run_paused`` — HITL pause on external tool execution. Agno does NOT emit ``tool_call_started`` / ``tool_call_completed`` for @@ -480,14 +710,14 @@ def _handle_run_paused(self, agno_event: Any, timestamp: Any) -> list[BaseAgentR return events - def _handle_tool_call_error(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_tool_call_error(self, agno_event: AgnoToolCallErrorEvent, timestamp: Any) -> list[BaseAgentRunEvent]: """Handle RunEvent.tool_call_error. Returns: List containing a ToolCallErrorEvent, or empty if already closed. """ - tool = getattr(agno_event, "tool", None) - tool_call_id = getattr(tool, "tool_call_id", None) if tool else None + tool = agno_event.tool + tool_call_id = tool.tool_call_id if tool else None if tool_call_id and tool_call_id in self._closed_tool_call_ids: logger.debug("Skipping duplicate ToolCallError for tool %s", tool_call_id) @@ -497,7 +727,7 @@ def _handle_tool_call_error(self, agno_event: Any, timestamp: Any) -> list[BaseA if tool: tool_info = ToolInfo( tool_call_id=tool_call_id, - tool_name=getattr(tool, "tool_name", None), + tool_name=tool.tool_name, tool_args=None, result=None, ) @@ -505,13 +735,14 @@ def _handle_tool_call_error(self, agno_event: Any, timestamp: Any) -> list[BaseA if tool_call_id: self._closed_tool_call_ids.add(tool_call_id) + content = agno_event.content return [ ToolCallErrorEvent( event=AgentRunEvent.TOOL_CALL_ERROR, tool=tool_info, - error_message=str(agno_event.content) if getattr(agno_event, "content", None) else None, + error_message=str(content) if content else None, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ] @@ -546,7 +777,7 @@ def _close_reasoning(self, timestamp: Any) -> list[BaseAgentRunEvent]: event=AgentRunEvent.REASONING_COMPLETED, reasoning_id=self._current_reasoning_id, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ] self._reasoning_active = False @@ -567,14 +798,14 @@ def _close_content(self, timestamp: Any) -> list[BaseAgentRunEvent]: event=AgentRunEvent.TEXT_MESSAGE_COMPLETED, message_id=self._current_message_id or "", timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ] self._content_active = False self._current_message_id = None return events - def _handle_run_content(self, agno_event: Any, timestamp: Any) -> list[BaseAgentRunEvent]: + def _handle_run_content(self, agno_event: AgnoRunContentEvent, timestamp: Any) -> list[BaseAgentRunEvent]: """Handle RunEvent.run_content — the core state machine. Rules: @@ -589,7 +820,7 @@ def _handle_run_content(self, agno_event: Any, timestamp: Any) -> list[BaseAgent """ events: list[BaseAgentRunEvent] = [] - reasoning_content = getattr(agno_event, "reasoning_content", None) + reasoning_content = agno_event.reasoning_content content = agno_event.content # ── Reasoning content handling ── @@ -634,7 +865,7 @@ def _process_reasoning_content(self, reasoning_content: str, timestamp: Any) -> event=AgentRunEvent.REASONING_STARTED, reasoning_id=self._current_reasoning_id, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ) self._reasoning_active = True @@ -645,7 +876,7 @@ def _process_reasoning_content(self, reasoning_content: str, timestamp: Any) -> delta=reasoning_content, reasoning_id=self._current_reasoning_id, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ) return events @@ -678,11 +909,28 @@ def _process_text_content(self, content: str, timestamp: Any) -> list[BaseAgentR event=AgentRunEvent.TEXT_MESSAGE_STARTED, message_id=self._current_message_id, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ) self._content_active = True + # Inject "--- ---" header when the newly-opened + # bubble belongs to a team member (nested agent event). + meta = self._last_metadata or {} + if meta.get("parent_run_id") and meta.get("source") == "agent": + name = meta.get("name") or "member" + events.append( + RunContentEvent( + event=AgentRunEvent.RUN_CONTENT, + content=f"\n --- \n ### {name} \n\n", + message_id=self._current_message_id, + reasoning_content=None, + content_type=None, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + events.append( RunContentEvent( event=AgentRunEvent.RUN_CONTENT, @@ -691,7 +939,7 @@ def _process_text_content(self, content: str, timestamp: Any) -> list[BaseAgentR reasoning_content=None, content_type=None, timestamp=timestamp, - metadata=None, + metadata=self._last_metadata, ) ) return events diff --git a/src/digitalkin/community/agno/agui_tools.py b/src/digitalkin/community/agno/agui_tools.py index 29782edf..076a099c 100644 --- a/src/digitalkin/community/agno/agui_tools.py +++ b/src/digitalkin/community/agno/agui_tools.py @@ -72,7 +72,7 @@ def agui_tool_to_external_function(tool: AgUiTool) -> Function: An :class:`agno.tools.function.Function` ready to be plugged into an Agno agent's tool list. """ - from agno.tools.function import Function # pyright: ignore[reportMissingImports] + from agno.tools.function import Function parameters = tool.parameters or {"type": "object", "properties": {}, "required": []} return Function( diff --git a/src/digitalkin/community/agno/hitl.py b/src/digitalkin/community/agno/hitl.py index 8704ad84..30fd6d63 100644 --- a/src/digitalkin/community/agno/hitl.py +++ b/src/digitalkin/community/agno/hitl.py @@ -504,7 +504,7 @@ async def run( :func:`emit_awaiting_tool_result` or let :meth:`handle_agui_input` do it). """ - from agno.run.agent import RunOutput # pyright: ignore[reportMissingImports] + from agno.run.agent import RunOutput logger.info("AgnoHitlRunner.run: starting (thread_id=%s, msg_len=%d)", thread_id, len(message)) @@ -553,7 +553,7 @@ async def continue_paused_run( the resumed run paused again (cascading frontend tools). If no paused record exists for ``thread_id``, returns ``None``. """ - from agno.run.agent import RunOutput # pyright: ignore[reportMissingImports] + from agno.run.agent import RunOutput record = await self._store.load(thread_id) if record is None: @@ -579,6 +579,8 @@ async def continue_paused_run( event=AgentRunEvent.RUN_STARTED, run_id=run_id, thread_id=thread_id, + timestamp=None, + metadata=None, ) ) @@ -702,6 +704,8 @@ async def try_resume( event=AgentRunEvent.RUN_STARTED, run_id=input_run_id, thread_id=thread_id, + timestamp=None, + metadata=None, ) ) await send( @@ -716,6 +720,9 @@ async def try_resume( "resume. The paused state has been preserved — retry once all " "tool results are available." ), + error_details=None, + timestamp=None, + metadata=None, ) ) return True, None diff --git a/src/digitalkin/mixins/agui_mixin.py b/src/digitalkin/mixins/agui_mixin.py index 52165a59..2fa160c1 100644 --- a/src/digitalkin/mixins/agui_mixin.py +++ b/src/digitalkin/mixins/agui_mixin.py @@ -16,6 +16,7 @@ from digitalkin.models.events import ( AgentRunEvent, BaseAgentRunEvent, + CustomEvent, ReasoningCompletedEvent, ReasoningContentDeltaEvent, ReasoningStartedEvent, @@ -54,7 +55,7 @@ async def execute(self, context, input_data): def __init__(self) -> None: """Initialize AG-UI mixin.""" super().__init__() - self._thread_id: str = str(uuid.uuid4()) + self._thread_id: str = "" self._run_id: str = "" async def _send_agui( # noqa: PLR6301 @@ -78,8 +79,10 @@ async def send_message( event: Agent run event to process and convert. """ context.callbacks.logger.debug( - "AG-UI event: %s", + "AG-UI event: %s thread_id=%s run_id=%s", event.event, + self._thread_id, + self._run_id, extra=context.session.current_ids(), ) @@ -101,6 +104,7 @@ async def send_message( AgentRunEvent.REASONING_CONTENT_DELTA: "_handle_reasoning_delta", AgentRunEvent.REASONING_STEP: "_handle_reasoning_step", AgentRunEvent.REASONING_COMPLETED: "_handle_reasoning_completed", + AgentRunEvent.CUSTOM: "_handle_custom", } # ── Private Event Handlers ─────────────────────────────────────────────── @@ -115,9 +119,20 @@ async def _handle_run_started( from digitalkin.models.module.ag_ui import AgUiRunStartedOutput # pylint: disable=C0415 - self._run_id = event.run_id or str(uuid.uuid4()) - if event.thread_id: - self._thread_id = event.thread_id + if not self._run_id: + self._run_id = event.run_id or str(uuid.uuid4()) + if not self._thread_id: + self._thread_id = event.thread_id or str(uuid.uuid4()) + + context.callbacks.logger.info( + "[agui-mixin] RUN_STARTED thread_id=%s run_id=%s event_run_id=%s event_thread_id=%s metadata=%s", + self._thread_id, + self._run_id, + event.run_id, + event.thread_id, + event.metadata, + extra=context.session.current_ids(), + ) output = AgUiRunStartedOutput( event=AgUiRunStartedEvent( @@ -198,7 +213,16 @@ async def _handle_run_completed( from digitalkin.models.module.ag_ui import AgUiRunFinishedOutput # pylint: disable=C0415 - run_id = event.run_id or self._run_id + run_id = self._run_id or event.run_id or str(uuid.uuid4()) + context.callbacks.logger.info( + "[agui-mixin] RUN_FINISHED thread_id=%s event_run_id=%s self._run_id=%s resolved=%s metadata=%s", + self._thread_id, + event.run_id, + self._run_id, + run_id, + event.metadata, + extra=context.session.current_ids(), + ) output = AgUiRunFinishedOutput( event=AgUiRunFinishedEvent( thread_id=self._thread_id, @@ -423,3 +447,21 @@ async def _handle_reasoning_completed( event=AgUiReasoningEndEvent(message_id=reasoning_id), ) await self._send_agui(context, end_output) + + async def _handle_custom( + self, + context: ModuleContext, + event: CustomEvent, + ) -> None: + """Handle custom event - emit AG-UI CustomEvent.""" + from ag_ui.core.events import CustomEvent as AgUiCustomEvent # pylint: disable=C0415 + + from digitalkin.models.module.ag_ui import AgUiCustomEventOutput # pylint: disable=C0415 + + output = AgUiCustomEventOutput( + event=AgUiCustomEvent( + name=event.name, + value=event.value, + ) + ) + await self._send_agui(context, output) diff --git a/src/digitalkin/models/events/__init__.py b/src/digitalkin/models/events/__init__.py index e315a2ec..416e6f26 100644 --- a/src/digitalkin/models/events/__init__.py +++ b/src/digitalkin/models/events/__init__.py @@ -7,6 +7,7 @@ from digitalkin.models.events.agent_events import ( AgentRunEvent, BaseAgentRunEvent, + CustomEvent, ReasoningCompletedEvent, ReasoningContentDeltaEvent, ReasoningStartedEvent, @@ -26,6 +27,7 @@ __all__ = [ "AgentRunEvent", "BaseAgentRunEvent", + "CustomEvent", "ReasoningCompletedEvent", "ReasoningContentDeltaEvent", "ReasoningStartedEvent", diff --git a/src/digitalkin/models/events/agent_events.py b/src/digitalkin/models/events/agent_events.py index 93daaa19..e87af5cf 100644 --- a/src/digitalkin/models/events/agent_events.py +++ b/src/digitalkin/models/events/agent_events.py @@ -32,6 +32,8 @@ class AgentRunEvent(str, Enum): TOOL_CALL_COMPLETED = "tool_call_completed" TOOL_CALL_ERROR = "tool_call_error" + CUSTOM = "custom" + class BaseAgentRunEvent(BaseModel): """Base class for all agent run events.""" @@ -157,3 +159,15 @@ class ToolCallErrorEvent(BaseAgentRunEvent): event: AgentRunEvent = Field(AgentRunEvent.TOOL_CALL_ERROR, description="Event type") tool: ToolInfo | None = Field(None, description="Tool information") error_message: str | None = Field(None, description="Error message") + + +class CustomEvent(BaseAgentRunEvent): + """Event emitted for application-defined custom events. + + Carries an application-specific ``name`` that discriminates the custom + event subtype and a free-form ``value`` payload for metadata transfer. + """ + + event: AgentRunEvent = Field(AgentRunEvent.CUSTOM, description="Event type") + name: str = Field(..., description="Application-defined event name (discriminator)") + value: Any = Field(..., description="Application-defined payload") diff --git a/tests/community/__init__.py b/tests/community/__init__.py index e69de29b..9c2311f1 100644 --- a/tests/community/__init__.py +++ b/tests/community/__init__.py @@ -0,0 +1 @@ +"""Tests for digitalkin.community integrations.""" diff --git a/tests/community/agno/__init__.py b/tests/community/agno/__init__.py new file mode 100644 index 00000000..c2f3ae74 --- /dev/null +++ b/tests/community/agno/__init__.py @@ -0,0 +1 @@ +"""Tests for digitalkin.community.agno integration.""" diff --git a/tests/community/agno/test_agno_adapter.py b/tests/community/agno/test_agno_adapter.py new file mode 100644 index 00000000..d09da9ee --- /dev/null +++ b/tests/community/agno/test_agno_adapter.py @@ -0,0 +1,1525 @@ +"""Full coverage tests for AgnoStreamAdapter. + +The ``agno`` package is an optional dependency and is not installed in the +test environment. These tests inject fake ``agno.run.agent`` and +``agno.run.team`` modules into ``sys.modules`` so the adapter's lazy import +resolves to controllable enum members and namespace objects. +""" + +from __future__ import annotations + +import sys +import types +from enum import Enum +from typing import Any + +import pytest + +from digitalkin.models.events import ( + AgentRunEvent, + ReasoningCompletedEvent, + ReasoningContentDeltaEvent, + ReasoningStartedEvent, + ReasoningStepEvent, + RunCompletedEvent, + RunContentEvent, + RunErrorEvent, + RunStartedEvent, + TextMessageCompletedEvent, + TextMessageStartedEvent, + ToolCallCompletedEvent, + ToolCallErrorEvent, + ToolCallStartedEvent, +) + + +class _FakeRunEvent(str, Enum): + """Mirror of ``agno.run.agent.RunEvent`` for tests.""" + + run_started = "RunStarted" + run_content = "RunContent" + run_completed = "RunCompleted" + run_error = "RunError" + run_paused = "RunPaused" + reasoning_started = "ReasoningStarted" + reasoning_content_delta = "ReasoningContentDelta" + reasoning_step = "ReasoningStep" + reasoning_completed = "ReasoningCompleted" + tool_call_started = "ToolCallStarted" + tool_call_completed = "ToolCallCompleted" + tool_call_error = "ToolCallError" + + +class _FakeTeamRunEvent(str, Enum): + """Mirror of ``agno.run.team.TeamRunEvent`` for tests.""" + + run_started = "TeamRunStarted" + run_content = "TeamRunContent" + run_completed = "TeamRunCompleted" + run_error = "TeamRunError" + run_paused = "TeamRunPaused" + reasoning_started = "TeamReasoningStarted" + reasoning_content_delta = "TeamReasoningContentDelta" + reasoning_step = "TeamReasoningStep" + reasoning_completed = "TeamReasoningCompleted" + tool_call_started = "TeamToolCallStarted" + tool_call_completed = "TeamToolCallCompleted" + tool_call_error = "TeamToolCallError" + + +@pytest.fixture(autouse=True) +def fake_agno_modules() -> Any: + """Install fake ``agno.run.agent`` and ``agno.run.team`` modules. + + Yields: + Tuple ``(_FakeRunEvent, _FakeTeamRunEvent)`` for convenience. + """ + saved = {k: sys.modules.get(k) for k in ("agno", "agno.run", "agno.run.agent", "agno.run.team")} + + agno_pkg = types.ModuleType("agno") + agno_run_pkg = types.ModuleType("agno.run") + agno_run_agent = types.ModuleType("agno.run.agent") + agno_run_agent.RunEvent = _FakeRunEvent # type: ignore[attr-defined] + agno_run_team = types.ModuleType("agno.run.team") + agno_run_team.TeamRunEvent = _FakeTeamRunEvent # type: ignore[attr-defined] + + sys.modules["agno"] = agno_pkg + sys.modules["agno.run"] = agno_run_pkg + sys.modules["agno.run.agent"] = agno_run_agent + sys.modules["agno.run.team"] = agno_run_team + + try: + yield _FakeRunEvent, _FakeTeamRunEvent + finally: + for key, mod in saved.items(): + if mod is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = mod + + +_EVENT_DEFAULTS: dict[str, Any] = { + "timestamp": 1234.5, + "run_id": None, + "session_id": None, + "parent_run_id": None, + "content": None, + "reasoning_content": None, + "tool": None, + "tools": None, + "requirements": None, + "error_type": None, + "team_name": None, + "team_id": None, + "agent_name": None, + "agent_id": None, +} + +_TOOL_DEFAULTS: dict[str, Any] = { + "tool_call_id": None, + "tool_name": None, + "tool_args": None, + "result": None, +} + +_TOOL_EXEC_DEFAULTS: dict[str, Any] = { + "tool_call_id": None, + "tool_name": None, + "tool_args": None, + "external_execution_required": False, +} + + +def _make_event(event: Any, **attrs: Any) -> types.SimpleNamespace: + """Build a namespace mimicking an Agno event object with Pydantic-like defaults.""" + data = {**_EVENT_DEFAULTS, **attrs} + return types.SimpleNamespace(event=event, **data) + + +def _make_tool(**attrs: Any) -> types.SimpleNamespace: + """Build a namespace mimicking an Agno ``ToolExecution`` (for tool_call_* events).""" + data = {**_TOOL_DEFAULTS, **attrs} + return types.SimpleNamespace(**data) + + +def _make_tool_execution(**attrs: Any) -> types.SimpleNamespace: + """Build a namespace mimicking an Agno ``ToolExecution`` attached to ``RunPausedEvent``.""" + data = {**_TOOL_EXEC_DEFAULTS, **attrs} + return types.SimpleNamespace(**data) + + +# ── Import / ImportError ──────────────────────────────────────────────────── + + +def test_import_error_when_agno_missing(monkeypatch: pytest.MonkeyPatch) -> None: + """First conversion raises ImportError with install hint if agno absent.""" + for key in ("agno", "agno.run", "agno.run.agent", "agno.run.team"): + monkeypatch.delitem(sys.modules, key, raising=False) + + real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__ # type: ignore[index] + + def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name.startswith("agno"): + raise ImportError(name) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", _blocked_import) + + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + event = _make_event("anything") + + with pytest.raises(ImportError, match="agno"): + adapter.to_digitalkin_events(event) + + +def test_dispatch_is_built_once() -> None: + """Dispatch table is lazily initialized on first call, reused on subsequent ones.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + assert adapter._dispatch is None + + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + dispatch_first = adapter._dispatch + assert dispatch_first is not None + + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_error)) + assert adapter._dispatch is dispatch_first + + +def test_unhandled_event_returns_empty() -> None: + """An event type absent from the dispatch table yields no DigitalKin events.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + + result = adapter.to_digitalkin_events(_make_event("unknown_event_type")) + assert result == [] + + +# ── Run lifecycle ─────────────────────────────────────────────────────────── + + +def test_run_started_emits_event() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_started, run_id="r1", session_id="t1"), + ) + + assert len(result) == 1 + event = result[0] + assert isinstance(event, RunStartedEvent) + assert event.run_id == "r1" + assert event.thread_id == "t1" + assert event.timestamp == 1234.5 + assert adapter._active_run_id == "r1" + + +def test_run_started_deduplicates_same_run_id() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + + duplicate = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + assert duplicate == [] + + +def test_run_started_without_run_id_is_not_deduped() -> None: + """``run_id is None`` bypasses the dedup guard and always emits.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + first = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id=None)) + second = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id=None)) + assert len(first) == 1 + assert len(second) == 1 + + +def test_run_completed_closes_active_sequences_and_emits() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + assert adapter._content_active is True + assert adapter._reasoning_active is False + + # Re-open reasoning so run_completed has both to close. + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think2", content=None), + ) + assert adapter._content_active is False + assert adapter._reasoning_active is True + + completed = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_completed, run_id="r1", content="final"), + ) + + kinds = [type(e) for e in completed] + assert ReasoningCompletedEvent in kinds + assert RunCompletedEvent in kinds + final = next(e for e in completed if isinstance(e, RunCompletedEvent)) + assert final.final_content == "final" + assert final.run_id == "r1" + assert "r1" in adapter._completed_run_ids + assert adapter._active_run_id is None + + +def test_run_completed_closes_only_active_text() -> None: + """When only text is active at run end, run_completed closes it then emits RunCompleted.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + assert adapter._content_active is True + assert adapter._reasoning_active is False + + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_completed, run_id="r1", content="final"), + ) + kinds = [type(e) for e in result] + assert TextMessageCompletedEvent in kinds + assert RunCompletedEvent in kinds + + +def test_run_completed_without_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_completed, run_id="r1", content=None), + ) + event = next(e for e in result if isinstance(e, RunCompletedEvent)) + assert event.final_content is None + + +def test_run_completed_deduplicates() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_completed, run_id="r1", content=None)) + + duplicate = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_completed, run_id="r1", content=None), + ) + assert duplicate == [] + + +def test_nested_run_started_is_dropped() -> None: + """A member agent's run (``parent_run_id`` set) must not surface as a new top-level run.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + + nested = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_started, + run_id="member-r1", + parent_run_id="team-r1", + agent_id="a1", + agent_name="Alice", + ), + ) + assert nested == [] + # Outer run state preserved + assert adapter._active_run_id == "team-r1" + + +def test_nested_run_completed_is_dropped() -> None: + """A member agent's ``run_completed`` must not close the outer team run.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + + nested = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_completed, + run_id="member-r1", + parent_run_id="team-r1", + content="member reply", + ), + ) + assert nested == [] + # Outer run still active + assert adapter._active_run_id == "team-r1" + assert "member-r1" not in adapter._completed_run_ids + + +def test_nested_member_content_still_propagates_with_metadata() -> None: + """Nested member content events keep flowing and carry ``parent_run_id`` in metadata.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + content="member reply", + parent_run_id="team-r1", + agent_id="a1", + agent_name="Alice", + ), + ) + assert any(isinstance(e, RunContentEvent) for e in result) + content_event = next(e for e in result if isinstance(e, RunContentEvent)) + metadata = content_event.metadata or {} + assert metadata["source"] == "agent" + assert metadata["name"] == "Alice" + assert metadata["parent_run_id"] == "team-r1" + + +def test_nested_run_completed_closes_open_subagent_text() -> None: + """Nested run_completed with active subagent text emits ``---`` footer then TextMessageCompleted.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + # Subagent text chunk opens a bubble (auto text_message_started + header). + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + content="hello from member", + parent_run_id="team-r1", + agent_name="Alice", + ), + ) + + closed = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_completed, + run_id="member-r1", + parent_run_id="team-r1", + content="member reply", + ), + ) + + kinds = [type(e) for e in closed] + # Order: footer RunContent("\n---\n") → TextMessageCompleted. No RunCompleted (nested is dropped). + assert kinds[0] is RunContentEvent + assert kinds[1] is TextMessageCompletedEvent + assert closed[0].content == " \n\n --- \n\n " + # Same message_id on both footer and close. + assert closed[0].message_id == closed[1].message_id + # Metadata still reflects the subagent. + metadata = closed[1].metadata or {} + assert metadata["parent_run_id"] == "team-r1" + # The nested run must NOT appear in completed ids (outer run keeps going). + assert "member-r1" not in adapter._completed_run_ids + + +def test_subagent_first_text_emits_header_delimiter() -> None: + """First subagent text chunk opens TextMessage + ``--- SubAgent ---`` header + real content.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + content="subagent text", + parent_run_id="team-r1", + agent_name="Alice", + ), + ) + + kinds = [type(e) for e in result] + # Order: TextMessageStarted → RunContent("--- SubAgent Alice ---\n") → RunContent(actual text). + assert kinds[0] is TextMessageStartedEvent + assert kinds[1] is RunContentEvent + assert kinds[2] is RunContentEvent + assert result[1].content == "\n --- \n ### Alice \n\n" + assert result[2].content == "subagent text" + # All three share the auto-minted subagent message_id. + assert result[0].message_id == result[1].message_id == result[2].message_id + + +def test_main_agent_text_after_subagent_gets_fresh_message_id_without_header() -> None: + """Main agent's continuation opens a NEW TextMessage with NO subagent header.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + sub = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + content="subagent text", + parent_run_id="team-r1", + agent_name="Alice", + ), + ) + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_completed, + run_id="member-r1", + parent_run_id="team-r1", + content="member reply", + ), + ) + main = adapter.to_digitalkin_events( + _make_event( + _FakeTeamRunEvent.run_content, + content="main text", + team_name="Leader", + ), + ) + + main_kinds = [type(e) for e in main] + # Main agent opens a fresh bubble with NO header — just TextMessageStarted then its content. + assert main_kinds[0] is TextMessageStartedEvent + assert main_kinds[1] is RunContentEvent + assert main[1].content == "main text" + # New message_id, different from the subagent's. + sub_message_id = sub[0].message_id + main_message_id = main[0].message_id + assert sub_message_id != main_message_id + + +def test_nested_run_completed_without_open_content_still_returns_empty() -> None: + """Nested run_completed with no active text/reasoning returns empty (regression guard).""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + # No content opened beforehand. + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_completed, + run_id="member-r1", + parent_run_id="team-r1", + content=None, + ), + ) + assert result == [] + + +def test_run_completed_without_run_id_still_emits() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id=None)) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_completed, run_id=None, content=None), + ) + assert any(isinstance(e, RunCompletedEvent) for e in result) + + +def test_run_error_emits_error_event() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_error, error_type="ValueError", content="boom"), + ) + assert len(result) == 1 + event = result[0] + assert isinstance(event, RunErrorEvent) + assert event.error_type == "ValueError" + assert event.content == "boom" + + +def test_run_error_without_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_error, error_type=None, content=None), + ) + event = result[0] + assert isinstance(event, RunErrorEvent) + assert event.content is None + + +# ── Reasoning explicit handlers ───────────────────────────────────────────── + + +def test_reasoning_started_closes_active_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hello"), + ) + assert adapter._content_active is True + + result = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + + kinds = [type(e) for e in result] + assert TextMessageCompletedEvent in kinds + assert ReasoningStartedEvent in kinds + assert adapter._reasoning_active is True + assert adapter._current_reasoning_id is not None + + +def test_reasoning_content_delta_passes_through() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_content_delta, reasoning_content="step"), + ) + assert len(result) == 1 + assert isinstance(result[0], ReasoningContentDeltaEvent) + assert result[0].delta == "step" + assert result[0].reasoning_id == adapter._current_reasoning_id + + +def test_reasoning_content_delta_without_content_defaults_to_empty() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_content_delta, reasoning_content=None), + ) + assert isinstance(result[0], ReasoningContentDeltaEvent) + assert result[0].delta == "" + + +def test_reasoning_step_reuses_active_reasoning() -> None: + """When reasoning is already active, reasoning_step appends without reopening.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + rid = adapter._current_reasoning_id + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="step body"), + ) + assert len(result) == 1 + assert isinstance(result[0], ReasoningStepEvent) + assert result[0].delta == "step body" + assert result[0].reasoning_id == rid + + +def test_reasoning_step_auto_opens_lifecycle() -> None: + """A reasoning_step without prior reasoning_started must auto-open the lifecycle.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="step"), + ) + kinds = [type(e) for e in result] + assert kinds == [ReasoningStartedEvent, ReasoningStepEvent] + assert adapter._reasoning_active is True + + +def test_reasoning_step_closes_active_content() -> None: + """A reasoning_step while text is active closes the text message first.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + assert adapter._content_active is True + + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="step"), + ) + kinds = [type(e) for e in result] + assert kinds[0] is TextMessageCompletedEvent + assert ReasoningStartedEvent in kinds + assert ReasoningStepEvent in kinds + + +def test_reasoning_step_empty_content_ignored() -> None: + """A reasoning_step with empty/absent content produces no events.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content=""), + ) + assert result == [] + assert adapter._reasoning_active is False + + +def test_multiple_reasoning_steps_share_lifecycle() -> None: + """Two consecutive reasoning_steps produce one lifecycle.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + first = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="a"), + ) + second = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="b"), + ) + kinds_first = [type(e) for e in first] + kinds_second = [type(e) for e in second] + assert kinds_first == [ReasoningStartedEvent, ReasoningStepEvent] + assert kinds_second == [ReasoningStepEvent] + + +def test_reasoning_completed_when_active() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + result = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_completed)) + assert len(result) == 1 + assert isinstance(result[0], ReasoningCompletedEvent) + assert adapter._reasoning_active is False + + +def test_reasoning_completed_when_inactive_returns_empty() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_completed)) + assert result == [] + + +# ── Tool call handlers ────────────────────────────────────────────────────── + + +def test_tool_call_started_closes_reasoning_and_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + adapter._reasoning_active = True + adapter._current_reasoning_id = "rid" + + tool = _make_tool(tool_call_id="tc1", tool_name="search", tool_args={"q": "x"}) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_started, tool=tool), + ) + + kinds = [type(e) for e in result] + assert ReasoningCompletedEvent in kinds + assert TextMessageCompletedEvent in kinds + assert ToolCallStartedEvent in kinds + started = next(e for e in result if isinstance(e, ToolCallStartedEvent)) + assert started.tool is not None + assert started.tool.tool_call_id == "tc1" + assert started.tool.tool_name == "search" + assert started.tool.tool_args == {"q": "x"} + + +def test_tool_call_started_without_tool() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_started, tool=None), + ) + assert len(result) == 1 + assert isinstance(result[0], ToolCallStartedEvent) + assert result[0].tool is None + + +def test_tool_call_completed() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool(tool_call_id="tc1", tool_name="search", tool_args={"q": "x"}, result="ok") + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_completed, tool=tool, content="ok"), + ) + assert len(result) == 1 + event = result[0] + assert isinstance(event, ToolCallCompletedEvent) + assert event.tool is not None + assert event.tool.result == "ok" + assert event.content == "ok" + assert "tc1" in adapter._closed_tool_call_ids + + +def test_tool_call_completed_without_tool_or_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_completed, tool=None, content=None), + ) + event = result[0] + assert isinstance(event, ToolCallCompletedEvent) + assert event.tool is None + assert event.content is None + + +def test_tool_call_error_first_time() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool(tool_call_id="tc1", tool_name="search") + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_error, tool=tool, content="boom"), + ) + assert len(result) == 1 + event = result[0] + assert isinstance(event, ToolCallErrorEvent) + assert event.error_message == "boom" + assert "tc1" in adapter._closed_tool_call_ids + + +def test_tool_call_error_after_completed_is_deduped() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool(tool_call_id="tc1", tool_name="search", tool_args=None, result="ok") + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_completed, tool=tool, content="ok"), + ) + duplicate = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_error, tool=tool, content="boom"), + ) + assert duplicate == [] + + +def test_tool_call_error_without_tool() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_error, tool=None, content="boom"), + ) + event = result[0] + assert isinstance(event, ToolCallErrorEvent) + assert event.tool is None + assert event.error_message == "boom" + + +def test_tool_call_error_without_tool_call_id_and_without_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool(tool_call_id=None, tool_name="search") + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_error, tool=tool, content=None), + ) + event = result[0] + assert isinstance(event, ToolCallErrorEvent) + assert event.tool is not None + assert event.tool.tool_call_id is None + assert event.error_message is None + + +# ── HITL pause (run_paused) ───────────────────────────────────────────────── + + +def test_run_paused_synthesizes_tool_events_for_external_tool() -> None: + """A single external tool yields a synthesized ToolCallStarted + ToolCallCompleted pair.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool_execution( + tool_call_id="ext1", + tool_name="get_weather", + tool_args={"city": "Lyon"}, + external_execution_required=True, + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[tool], requirements=[]), + ) + kinds = [type(e) for e in result] + assert kinds == [ToolCallStartedEvent, ToolCallCompletedEvent] + started = result[0] + completed = result[1] + assert isinstance(started, ToolCallStartedEvent) + assert isinstance(completed, ToolCallCompletedEvent) + assert started.tool is not None + assert started.tool.tool_name == "get_weather" + assert completed.content is None + assert completed.tool is not None + assert completed.tool.result is None + assert adapter.is_paused is True + assert "ext1" in adapter._closed_tool_call_ids + + +def test_run_paused_skips_backend_only_tools() -> None: + """Server-side tools (external_execution_required=False) must not be synthesized.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + backend = _make_tool_execution( + tool_call_id="tc-think", + tool_name="think", + external_execution_required=False, + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[backend], requirements=[]), + ) + tool_events = [e for e in result if isinstance(e, (ToolCallStartedEvent, ToolCallCompletedEvent))] + assert tool_events == [] + assert adapter.is_paused is True + + +def test_run_paused_deduplicates_repeated_tool_call_ids() -> None: + """Agno may accumulate tools across yields; duplicate ids emit only one pair.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + ext1 = _make_tool_execution( + tool_call_id="ext-ask", + tool_name="ask_question", + external_execution_required=True, + ) + ext1_dup = _make_tool_execution( + tool_call_id="ext-ask", + tool_name="ask_question", + external_execution_required=True, + ) + ext2 = _make_tool_execution( + tool_call_id="ext-map", + tool_name="show_map", + external_execution_required=True, + ) + backend = _make_tool_execution( + tool_call_id="tc-think", + tool_name="think", + external_execution_required=False, + ) + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_paused, + tools=[backend, ext1, ext1_dup, ext2], + requirements=[], + ), + ) + kinds = [type(e) for e in result] + assert kinds == [ + ToolCallStartedEvent, + ToolCallCompletedEvent, + ToolCallStartedEvent, + ToolCallCompletedEvent, + ] + names = [e.tool.tool_name for e in result if e.tool is not None] + assert names == ["ask_question", "ask_question", "show_map", "show_map"] + + +def test_run_paused_skips_external_tool_without_tool_call_id() -> None: + """External tools missing ``tool_call_id`` cannot be identified; they are skipped.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool_execution( + tool_call_id=None, + tool_name="anon", + external_execution_required=True, + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[tool], requirements=[]), + ) + tool_events = [e for e in result if isinstance(e, (ToolCallStartedEvent, ToolCallCompletedEvent))] + assert tool_events == [] + assert adapter.is_paused is True + + +def test_run_paused_closes_active_content_and_reasoning() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + # Open text content + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="thinking"), + ) + # Force reasoning active too to exercise both branches + adapter._reasoning_active = True + adapter._current_reasoning_id = "rid" + + tool = _make_tool_execution( + tool_call_id="ext1", + tool_name="ask", + external_execution_required=True, + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[tool], requirements=[]), + ) + kinds = [type(e) for e in result] + assert kinds[0] is ReasoningCompletedEvent + assert TextMessageCompletedEvent in kinds + idx_close_text = kinds.index(TextMessageCompletedEvent) + idx_start_tool = kinds.index(ToolCallStartedEvent) + assert idx_close_text < idx_start_tool + + +def test_run_paused_records_paused_state_and_properties() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + assert adapter.is_paused is False + assert adapter.paused_tool_executions == [] + assert adapter.paused_requirements == [] + + tool = _make_tool_execution( + tool_call_id="ext1", + tool_name="ask", + external_execution_required=True, + ) + requirement = types.SimpleNamespace(tool_execution=tool) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[tool], requirements=[requirement]), + ) + + assert adapter.is_paused is True + assert len(adapter.paused_tool_executions) == 1 + assert adapter.paused_tool_executions[0] is tool + assert len(adapter.paused_requirements) == 1 + assert adapter.paused_requirements[0] is requirement + + +def test_run_paused_with_null_tools_and_requirements_lists() -> None: + """``tools=None`` and ``requirements=None`` are normalised to empty lists.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=None, requirements=None), + ) + assert result == [] + assert adapter.is_paused is True + assert adapter.paused_tool_executions == [] + assert adapter.paused_requirements == [] + + +# ── run_content state machine ─────────────────────────────────────────────── + + +def test_run_content_text_auto_opens_and_deltas() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hello"), + ) + assert isinstance(result[0], TextMessageStartedEvent) + assert isinstance(result[1], RunContentEvent) + assert result[1].content == "hello" + assert result[1].message_id == adapter._current_message_id + + result2 = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content=" world"), + ) + assert len(result2) == 1 + assert isinstance(result2[0], RunContentEvent) + + +def test_run_content_reasoning_auto_opens_and_deltas() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + assert isinstance(result[0], ReasoningStartedEvent) + assert isinstance(result[1], ReasoningContentDeltaEvent) + + result2 = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="more", content=None), + ) + assert len(result2) == 1 + assert isinstance(result2[0], ReasoningContentDeltaEvent) + + +def test_run_content_transition_reasoning_to_text_closes_reasoning() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + kinds = [type(e) for e in result] + assert ReasoningCompletedEvent in kinds + assert TextMessageStartedEvent in kinds + assert RunContentEvent in kinds + + +def test_run_content_transition_text_to_reasoning_closes_text() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + kinds = [type(e) for e in result] + assert TextMessageCompletedEvent in kinds + assert ReasoningStartedEvent in kinds + assert ReasoningContentDeltaEvent in kinds + + +def test_run_content_empty_content_closes_active_text() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content=""), + ) + assert len(result) == 1 + assert isinstance(result[0], TextMessageCompletedEvent) + assert adapter._content_active is False + + +def test_run_content_empty_content_when_inactive_is_noop() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content=""), + ) + assert result == [] + + +def test_run_content_empty_reasoning_closes_active_reasoning() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="", content=None), + ) + assert len(result) == 1 + assert isinstance(result[0], ReasoningCompletedEvent) + assert adapter._reasoning_active is False + + +def test_run_content_empty_reasoning_when_inactive_is_noop() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="", content=None), + ) + assert result == [] + + +def test_run_content_both_none_is_debug_noop() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content=None), + ) + assert result == [] + + +def test_run_content_reasoning_after_explicit_started() -> None: + """When reasoning_started already fired, a reasoning delta must not re-open.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + rid = adapter._current_reasoning_id + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="step", content=None), + ) + assert len(result) == 1 + assert isinstance(result[0], ReasoningContentDeltaEvent) + assert adapter._current_reasoning_id == rid + + +# ── flush() ───────────────────────────────────────────────────────────────── + + +def test_close_content_noop_when_inactive() -> None: + """Direct call returns empty list (defensive early exit in ``_close_content``).""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + assert adapter._close_content(None) == [] + + +def test_close_reasoning_noop_when_inactive() -> None: + """Direct call returns empty list (defensive early exit in ``_close_reasoning``).""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + assert adapter._close_reasoning(None) == [] + + +def test_flush_empty() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + assert adapter.flush() == [] + + +def test_flush_closes_active_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + result = adapter.flush() + assert len(result) == 1 + assert isinstance(result[0], TextMessageCompletedEvent) + assert adapter._content_active is False + + +def test_flush_closes_active_reasoning() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + result = adapter.flush() + assert len(result) == 1 + assert isinstance(result[0], ReasoningCompletedEvent) + assert adapter._reasoning_active is False + + +def test_flush_closes_both_when_both_forced() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + # Force content active too (normally mutually exclusive) to exercise both branches + adapter._content_active = True + adapter._current_message_id = "m1" + + result = adapter.flush() + kinds = [type(e) for e in result] + assert TextMessageCompletedEvent in kinds + assert ReasoningCompletedEvent in kinds + + +def test_double_flush_is_idempotent() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + adapter.flush() + assert adapter.flush() == [] + + +# ── Team events share the same dispatch ───────────────────────────────────── + + +def test_team_run_started_dispatches() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_started, run_id="team-r1", session_id="t-t1"), + ) + assert isinstance(result[0], RunStartedEvent) + assert result[0].run_id == "team-r1" + assert result[0].thread_id == "t-t1" + + +def test_team_events_route_like_agent_events() -> None: + """Smoke-test that each TeamRunEvent key resolves to the matching handler.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_content, reasoning_content="r", content=None), + ) + adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.reasoning_step, reasoning_content="s"), + ) + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.reasoning_completed)) + tool = _make_tool(tool_call_id="tc", tool_name="t", tool_args=None, result="r") + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.tool_call_started, tool=tool)) + adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.tool_call_completed, tool=tool, content=None), + ) + err_tool = _make_tool(tool_call_id="tc2", tool_name="t") + adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.tool_call_error, tool=err_tool, content="x"), + ) + adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_error, error_type=None, content=None), + ) + ext_tool = _make_tool_execution( + tool_call_id="ext-team", + tool_name="frontend", + external_execution_required=True, + ) + paused = adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_paused, tools=[ext_tool], requirements=[]), + ) + assert any(isinstance(e, ToolCallStartedEvent) for e in paused) + completed = adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_completed, run_id="r1", content=None), + ) + assert any(isinstance(e, RunCompletedEvent) for e in completed) + + +# ── Metadata propagation ──────────────────────────────────────────────────── + + +def test_metadata_agent_event_has_source_and_identity() -> None: + """Agent-scoped events populate ``metadata`` with agent identity. + + Uses ``run_content`` (not ``run_started``) because nested ``run_started`` + events are dropped at the adapter boundary to preserve the single- + ``RUN_STARTED`` AG-UI contract; ``run_content`` propagates through. + """ + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + content="hello", + agent_id="a1", + agent_name="Alice", + parent_run_id="team-r1", + ), + ) + content_event = next(e for e in result if isinstance(e, RunContentEvent)) + assert content_event.metadata == { + "source": "agent", + "name": "Alice", + "id": "a1", + "parent_run_id": "team-r1", + } + + +def test_metadata_team_event_has_source_and_identity() -> None: + """Team-scoped events populate ``metadata`` with team identity.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event( + _FakeTeamRunEvent.run_started, + run_id="tr1", + team_id="t1", + team_name="CrewA", + ), + ) + assert result[0].metadata == { + "source": "team", + "name": "CrewA", + "id": "t1", + "parent_run_id": None, + } + + +def test_metadata_does_not_duplicate_run_id() -> None: + """``run_id`` is already on typed fields and must not appear in metadata.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_started, run_id="r1", agent_name="Alice"), + ) + event = result[0] + metadata = event.metadata or {} + assert "run_id" not in metadata + assert isinstance(event, RunStartedEvent) + assert event.run_id == "r1" + + +def test_metadata_not_polluted_by_unhandled_event() -> None: + """Unhandled events must not overwrite the cached ``_last_metadata``.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_started, run_id="r1", agent_name="Alice", agent_id="a1"), + ) + snapshot = dict(adapter._last_metadata or {}) + assert snapshot["name"] == "Alice" + + adapter.to_digitalkin_events(_make_event("completely_unknown_event")) + + assert adapter._last_metadata == snapshot + + +def test_metadata_propagates_to_text_message_events() -> None: + """Text message sequence events inherit the emitter's metadata.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_started, run_id="r1", agent_name="Alice", agent_id="a1"), + ) + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + agent_name="Alice", + agent_id="a1", + content="hello", + ), + ) + for event in result: + assert (event.metadata or {}).get("name") == "Alice" + assert (event.metadata or {}).get("source") == "agent" + + +def test_metadata_switches_between_team_and_agent_events() -> None: + """Switching emitters between events yields distinct metadata dicts.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + team_result = adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_started, run_id="tr1", team_id="t1", team_name="CrewA"), + ) + assert (team_result[0].metadata or {})["source"] == "team" + + agent_result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + agent_id="a1", + agent_name="Alice", + parent_run_id="tr1", + content="hi", + ), + ) + for event in agent_result: + metadata = event.metadata or {} + assert metadata["source"] == "agent" + assert metadata["name"] == "Alice" + assert metadata["parent_run_id"] == "tr1" + + +# ── Full realistic sequences ──────────────────────────────────────────────── + + +def test_realistic_sequence_with_reasoning_text_tool_and_pause() -> None: + """think → reasoning_step → text → search → analyze → reasoning_step → text → frontend pause.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + events: list[Any] = [] + + events.extend(adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1"))) + events.extend( + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.tool_call_started, + tool=_make_tool(tool_call_id="tc-think", tool_name="think"), + ), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.tool_call_completed, + tool=_make_tool(tool_call_id="tc-think", tool_name="think", result="planned"), + content="planned", + ), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="## Plan"), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="Searching..."), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.tool_call_started, + tool=_make_tool(tool_call_id="tc-search", tool_name="web_search"), + ), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.tool_call_completed, + tool=_make_tool(tool_call_id="tc-search", tool_name="web_search", result="results"), + content="results", + ), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="## Analysis"), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="Here!"), + ), + ) + backend = _make_tool_execution( + tool_call_id="tc-think", + tool_name="think", + external_execution_required=False, + ) + frontend = _make_tool_execution( + tool_call_id="ext-show", + tool_name="show_sources", + external_execution_required=True, + ) + events.extend( + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[backend, frontend], requirements=[]), + ), + ) + + kinds = [type(e) for e in events] + + # Reasoning lifecycle: each REASONING_STARTED has a matching REASONING_COMPLETED + starts = [i for i, k in enumerate(kinds) if k is ReasoningStartedEvent] + ends = [i for i, k in enumerate(kinds) if k is ReasoningCompletedEvent] + assert len(starts) == len(ends) + for start, end in zip(starts, ends, strict=True): + assert start < end + + # Text message lifecycle balanced + text_starts = [i for i, k in enumerate(kinds) if k is TextMessageStartedEvent] + text_ends = [i for i, k in enumerate(kinds) if k is TextMessageCompletedEvent] + assert len(text_starts) == len(text_ends) + + # Paused at the end with exactly one synthesised external tool pair + assert adapter.is_paused is True + synthesised = [ + e for e in events[-2:] if isinstance(e, (ToolCallStartedEvent, ToolCallCompletedEvent)) + ] + assert len(synthesised) == 2 + + +# ── Enum value exposure ───────────────────────────────────────────────────── + + +def test_event_types_use_enum_values_in_serialization() -> None: + """Pydantic config ``use_enum_values`` keeps payloads as plain strings.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + assert result[0].event == AgentRunEvent.RUN_STARTED.value diff --git a/tests/community/test_agno_adapter.py b/tests/community/test_agno_adapter.py deleted file mode 100644 index d3a4ebae..00000000 --- a/tests/community/test_agno_adapter.py +++ /dev/null @@ -1,799 +0,0 @@ -"""Tests for AgnoStreamAdapter — Agno events → DigitalKin events. - -Covers: -- Run lifecycle (started, completed, error, duplicates) -- Reasoning lifecycle (native: started/delta/completed) -- Reasoning via ReasoningTools (reasoning_step auto-wrapping) -- Text content (auto-open/close text messages) -- Tool calls (started, completed, error, deduplication) -- HITL pause (run_paused → synthesized tool calls + is_paused) -- State transitions & overlaps (reasoning→content, content→tool, reasoning→tool, etc.) -- Flush (close dangling sequences) -""" - -from __future__ import annotations - -from types import SimpleNamespace -from typing import Any - -import pytest - -from digitalkin.models.events import AgentRunEvent - -# Lazy-import guard: the adapter imports agno at first use. -# We mock RunEvent with a SimpleNamespace so tests don't need agno installed. - - -def _make_event(event_type: str, **kwargs: Any) -> SimpleNamespace: - """Build a fake Agno event (duck-typed).""" - return SimpleNamespace(event=event_type, timestamp=1234, **kwargs) - - -# ── Agno RunEvent values (mirrors agno.run.agent.RunEvent enum) ────────── - -# We patch the dispatch dict directly so we don't need the real agno package. - - -class _FakeRunEvent: - run_started = "RunStarted" - run_content = "RunContent" - run_completed = "RunCompleted" - run_error = "RunError" - run_paused = "RunPaused" - reasoning_started = "ReasoningStarted" - reasoning_content_delta = "ReasoningContentDelta" - reasoning_step = "ReasoningStep" - reasoning_completed = "ReasoningCompleted" - tool_call_started = "ToolCallStarted" - tool_call_completed = "ToolCallCompleted" - tool_call_error = "ToolCallError" - - -def _create_adapter(): - """Create an adapter with the dispatch table pre-initialized (no agno import).""" - from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter - - adapter = AgnoStreamAdapter() - # Force-init the dispatch table without importing agno - adapter._dispatch = { - _FakeRunEvent.run_started: adapter._handle_run_started, - _FakeRunEvent.run_content: adapter._handle_run_content, - _FakeRunEvent.run_completed: adapter._handle_run_completed, - _FakeRunEvent.run_error: adapter._handle_run_error, - _FakeRunEvent.run_paused: adapter._handle_run_paused, - _FakeRunEvent.reasoning_started: adapter._handle_reasoning_started, - _FakeRunEvent.reasoning_content_delta: adapter._handle_reasoning_content_delta, - _FakeRunEvent.reasoning_step: adapter._handle_reasoning_step, - _FakeRunEvent.reasoning_completed: adapter._handle_reasoning_completed, - _FakeRunEvent.tool_call_started: adapter._handle_tool_call_started, - _FakeRunEvent.tool_call_completed: adapter._handle_tool_call_completed, - _FakeRunEvent.tool_call_error: adapter._handle_tool_call_error, - } - return adapter - - -def _event_types(events) -> list[str]: - """Extract event type strings for easy assertion.""" - return [e.event.value if hasattr(e.event, "value") else str(e.event) for e in events] - - -# ═══════════════════════════════════════════════════════════════════════════ -# 1. RUN LIFECYCLE -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestRunLifecycle: - def test_run_started(self): - adapter = _create_adapter() - events = adapter.to_digitalkin_events(_make_event("RunStarted", run_id="r1", thread_id="t1")) - assert _event_types(events) == [AgentRunEvent.RUN_STARTED] - assert events[0].run_id == "r1" - assert events[0].thread_id == "t1" - - def test_run_started_duplicate_skipped(self): - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("RunStarted", run_id="r1", thread_id="t1")) - events = adapter.to_digitalkin_events(_make_event("RunStarted", run_id="r1", thread_id="t1")) - assert events == [] - - def test_run_completed(self): - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("RunStarted", run_id="r1", thread_id="t1")) - events = adapter.to_digitalkin_events(_make_event("RunCompleted", run_id="r1", content="done")) - assert AgentRunEvent.RUN_COMPLETED in _event_types(events) - - def test_run_completed_duplicate_skipped(self): - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("RunStarted", run_id="r1", thread_id="t1")) - adapter.to_digitalkin_events(_make_event("RunCompleted", run_id="r1", content="done")) - events = adapter.to_digitalkin_events(_make_event("RunCompleted", run_id="r1", content="done")) - assert events == [] - - def test_run_error(self): - adapter = _create_adapter() - events = adapter.to_digitalkin_events( - _make_event("RunError", error_type="CRASH", content="something broke") - ) - assert _event_types(events) == [AgentRunEvent.RUN_ERROR] - assert events[0].content == "something broke" - - def test_unknown_event_ignored(self): - adapter = _create_adapter() - events = adapter.to_digitalkin_events(_make_event("SomeNewEvent")) - assert events == [] - - -# ═══════════════════════════════════════════════════════════════════════════ -# 2. NATIVE REASONING (reasoning_started / content_delta / completed) -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestNativeReasoning: - def test_full_reasoning_lifecycle(self): - adapter = _create_adapter() - all_events = [] - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStarted"))) - all_events.extend( - adapter.to_digitalkin_events(_make_event("ReasoningContentDelta", reasoning_content="thinking...")) - ) - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningCompleted"))) - - types = _event_types(all_events) - assert types == [ - AgentRunEvent.REASONING_STARTED, - AgentRunEvent.REASONING_CONTENT_DELTA, - AgentRunEvent.REASONING_COMPLETED, - ] - - def test_reasoning_started_closes_active_content(self): - """If text is streaming and reasoning starts, text must close first.""" - adapter = _create_adapter() - all_events = [] - # Start text - all_events.extend( - adapter.to_digitalkin_events(_make_event("RunContent", content="hello", reasoning_content=None)) - ) - # Now reasoning starts → text should close - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStarted"))) - - types = _event_types(all_events) - assert AgentRunEvent.TEXT_MESSAGE_STARTED in types - assert AgentRunEvent.TEXT_MESSAGE_COMPLETED in types - # TEXT_MESSAGE_COMPLETED must come BEFORE REASONING_STARTED - assert types.index(AgentRunEvent.TEXT_MESSAGE_COMPLETED) < types.index(AgentRunEvent.REASONING_STARTED) - - def test_reasoning_completed_when_not_active_returns_empty(self): - adapter = _create_adapter() - events = adapter.to_digitalkin_events(_make_event("ReasoningCompleted")) - assert events == [] - - -# ═══════════════════════════════════════════════════════════════════════════ -# 3. REASONING VIA ReasoningTools (reasoning_step auto-wrap) -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestReasoningStep: - def test_reasoning_step_auto_opens_lifecycle(self): - """A reasoning_step without prior reasoning_started must auto-wrap.""" - adapter = _create_adapter() - events = adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="step content")) - - types = _event_types(events) - assert types == [AgentRunEvent.REASONING_STARTED, AgentRunEvent.REASONING_STEP] - assert adapter._reasoning_active is True - - def test_reasoning_step_reuses_active_reasoning(self): - """If reasoning is already active, step doesn't re-open.""" - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("ReasoningStarted")) - events = adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="more thinking")) - - types = _event_types(events) - assert types == [AgentRunEvent.REASONING_STEP] - # No extra REASONING_STARTED - - def test_reasoning_step_closes_active_content(self): - """If text is active when reasoning_step arrives, text closes first.""" - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("RunContent", content="hello", reasoning_content=None)) - assert adapter._content_active is True - - events = adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="step")) - types = _event_types(events) - assert types[0] == AgentRunEvent.TEXT_MESSAGE_COMPLETED - assert AgentRunEvent.REASONING_STARTED in types - assert AgentRunEvent.REASONING_STEP in types - - def test_reasoning_step_empty_content_ignored(self): - adapter = _create_adapter() - events = adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="")) - assert events == [] - assert adapter._reasoning_active is False - - def test_reasoning_step_then_text_auto_closes_reasoning(self): - """After auto-opened reasoning_step, text content auto-closes reasoning.""" - adapter = _create_adapter() - all_events = [] - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="step"))) - assert adapter._reasoning_active is True - - all_events.extend( - adapter.to_digitalkin_events(_make_event("RunContent", content="answer", reasoning_content=None)) - ) - - types = _event_types(all_events) - # reasoning_step auto-opens, then text closes reasoning and opens text - assert AgentRunEvent.REASONING_STARTED in types - assert AgentRunEvent.REASONING_STEP in types - assert AgentRunEvent.REASONING_COMPLETED in types - assert AgentRunEvent.TEXT_MESSAGE_STARTED in types - # Order: REASONING_COMPLETED before TEXT_MESSAGE_STARTED - assert types.index(AgentRunEvent.REASONING_COMPLETED) < types.index(AgentRunEvent.TEXT_MESSAGE_STARTED) - - def test_multiple_reasoning_steps_single_lifecycle(self): - """Multiple consecutive reasoning_steps share one lifecycle.""" - adapter = _create_adapter() - all_events = [] - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="step 1"))) - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="step 2"))) - - types = _event_types(all_events) - # Only one REASONING_STARTED at the beginning - assert types.count(AgentRunEvent.REASONING_STARTED) == 1 - assert types.count(AgentRunEvent.REASONING_STEP) == 2 - - -# ═══════════════════════════════════════════════════════════════════════════ -# 4. TEXT CONTENT (run_content) -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestTextContent: - def test_text_content_auto_opens_message(self): - adapter = _create_adapter() - events = adapter.to_digitalkin_events(_make_event("RunContent", content="hello", reasoning_content=None)) - types = _event_types(events) - assert types == [AgentRunEvent.TEXT_MESSAGE_STARTED, AgentRunEvent.RUN_CONTENT] - - def test_text_content_subsequent_chunks_no_reopen(self): - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("RunContent", content="hello", reasoning_content=None)) - events = adapter.to_digitalkin_events(_make_event("RunContent", content=" world", reasoning_content=None)) - types = _event_types(events) - assert types == [AgentRunEvent.RUN_CONTENT] - - def test_empty_text_closes_message(self): - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("RunContent", content="hello", reasoning_content=None)) - events = adapter.to_digitalkin_events(_make_event("RunContent", content="", reasoning_content=None)) - types = _event_types(events) - assert types == [AgentRunEvent.TEXT_MESSAGE_COMPLETED] - - def test_text_content_closes_active_reasoning(self): - """Text after reasoning auto-closes reasoning.""" - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("ReasoningStarted")) - adapter.to_digitalkin_events(_make_event("ReasoningContentDelta", reasoning_content="think")) - events = adapter.to_digitalkin_events(_make_event("RunContent", content="answer", reasoning_content=None)) - - types = _event_types(events) - assert types[0] == AgentRunEvent.REASONING_COMPLETED - assert AgentRunEvent.TEXT_MESSAGE_STARTED in types - - -# ═══════════════════════════════════════════════════════════════════════════ -# 5. TOOL CALLS -# ═══════════════════════════════════════════════════════════════════════════ - - -def _make_tool( - tool_call_id="tc1", - tool_name="search", - tool_args=None, - result=None, - external_execution_required=False, -): - return SimpleNamespace( - tool_call_id=tool_call_id, - tool_name=tool_name, - tool_args=tool_args or {"q": "test"}, - result=result, - external_execution_required=external_execution_required, - ) - - -class TestToolCalls: - def test_tool_call_started(self): - adapter = _create_adapter() - events = adapter.to_digitalkin_events( - _make_event("ToolCallStarted", tool=_make_tool()) - ) - types = _event_types(events) - assert types == [AgentRunEvent.TOOL_CALL_STARTED] - assert events[0].tool.tool_name == "search" - - def test_tool_call_completed(self): - adapter = _create_adapter() - events = adapter.to_digitalkin_events( - _make_event("ToolCallCompleted", tool=_make_tool(result="found"), content="found") - ) - types = _event_types(events) - assert types == [AgentRunEvent.TOOL_CALL_COMPLETED] - assert events[0].tool.result == "found" - - def test_tool_call_started_closes_reasoning_and_content(self): - """Tool call must close both reasoning and content if active.""" - adapter = _create_adapter() - all_events = [] - # Open reasoning - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStarted"))) - # Tool call starts → reasoning closes - all_events.extend( - adapter.to_digitalkin_events(_make_event("ToolCallStarted", tool=_make_tool())) - ) - types = _event_types(all_events) - assert AgentRunEvent.REASONING_COMPLETED in types - assert types.index(AgentRunEvent.REASONING_COMPLETED) < types.index(AgentRunEvent.TOOL_CALL_STARTED) - - def test_tool_call_error(self): - adapter = _create_adapter() - events = adapter.to_digitalkin_events( - _make_event("ToolCallError", tool=_make_tool(), content="timeout") - ) - types = _event_types(events) - assert types == [AgentRunEvent.TOOL_CALL_ERROR] - - def test_tool_call_error_dedup(self): - """Duplicate tool_call_error for same ID is skipped.""" - adapter = _create_adapter() - adapter.to_digitalkin_events( - _make_event("ToolCallCompleted", tool=_make_tool(tool_call_id="tc1"), content="ok") - ) - events = adapter.to_digitalkin_events( - _make_event("ToolCallError", tool=_make_tool(tool_call_id="tc1"), content="err") - ) - assert events == [] - - -# ═══════════════════════════════════════════════════════════════════════════ -# 6. HITL PAUSE (run_paused → synthesized tool calls) -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestRunPaused: - def test_run_paused_synthesizes_tool_events(self): - adapter = _create_adapter() - tool1 = _make_tool(tool_call_id="ext1", tool_name="get_weather", tool_args={"city": "Lyon"}, external_execution_required=True) - events = adapter.to_digitalkin_events( - _make_event("RunPaused", tools=[tool1], requirements=[]) - ) - types = _event_types(events) - assert types == [AgentRunEvent.TOOL_CALL_STARTED, AgentRunEvent.TOOL_CALL_COMPLETED] - assert events[0].tool.tool_name == "get_weather" - assert events[1].content is None # no result (external tool) - assert adapter.is_paused is True - - def test_run_paused_multiple_tools(self): - adapter = _create_adapter() - tool1 = _make_tool(tool_call_id="ext1", tool_name="get_weather", external_execution_required=True) - tool2 = _make_tool(tool_call_id="ext2", tool_name="select_items", external_execution_required=True) - events = adapter.to_digitalkin_events( - _make_event("RunPaused", tools=[tool1, tool2], requirements=[]) - ) - types = _event_types(events) - assert types == [ - AgentRunEvent.TOOL_CALL_STARTED, - AgentRunEvent.TOOL_CALL_COMPLETED, - AgentRunEvent.TOOL_CALL_STARTED, - AgentRunEvent.TOOL_CALL_COMPLETED, - ] - assert adapter.paused_tool_executions[0].tool_name == "get_weather" - assert adapter.paused_tool_executions[1].tool_name == "select_items" - - def test_run_paused_closes_active_reasoning_and_content(self): - adapter = _create_adapter() - all_events = [] - all_events.extend( - adapter.to_digitalkin_events(_make_event("RunContent", content="checking", reasoning_content=None)) - ) - assert adapter._content_active is True - - tool1 = _make_tool(tool_call_id="ext1", tool_name="get_weather", external_execution_required=True) - all_events.extend( - adapter.to_digitalkin_events(_make_event("RunPaused", tools=[tool1], requirements=[])) - ) - types = _event_types(all_events) - # Content must close before synthesized tool events - assert AgentRunEvent.TEXT_MESSAGE_COMPLETED in types - idx_close = types.index(AgentRunEvent.TEXT_MESSAGE_COMPLETED) - idx_tool = types.index(AgentRunEvent.TOOL_CALL_STARTED) - assert idx_close < idx_tool - - def test_run_paused_properties(self): - adapter = _create_adapter() - assert adapter.is_paused is False - assert adapter.paused_tool_executions == [] - assert adapter.paused_requirements == [] - - tool = _make_tool(tool_call_id="ext1", tool_name="get_weather", external_execution_required=True) - req = SimpleNamespace(tool_execution=tool) - adapter.to_digitalkin_events(_make_event("RunPaused", tools=[tool], requirements=[req])) - - assert adapter.is_paused is True - assert len(adapter.paused_tool_executions) == 1 - assert len(adapter.paused_requirements) == 1 - - -# ═══════════════════════════════════════════════════════════════════════════ -# 7. STATE TRANSITIONS & OVERLAPS -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestStateTransitions: - def test_reasoning_to_content_to_tool_to_content(self): - """Complex sequence: reasoning → text → tool → text → completed.""" - adapter = _create_adapter() - all_events = [] - - # 1. Reasoning - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStarted"))) - all_events.extend( - adapter.to_digitalkin_events(_make_event("ReasoningContentDelta", reasoning_content="think")) - ) - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningCompleted"))) - - # 2. Text - all_events.extend( - adapter.to_digitalkin_events(_make_event("RunContent", content="I'll search", reasoning_content=None)) - ) - - # 3. Tool call - all_events.extend( - adapter.to_digitalkin_events(_make_event("ToolCallStarted", tool=_make_tool())) - ) - all_events.extend( - adapter.to_digitalkin_events( - _make_event("ToolCallCompleted", tool=_make_tool(result="found"), content="found") - ) - ) - - # 4. More text - all_events.extend( - adapter.to_digitalkin_events(_make_event("RunContent", content="Here's what I found", reasoning_content=None)) - ) - - # 5. Completed - all_events.extend(adapter.to_digitalkin_events(_make_event("RunCompleted", run_id="r1", content="done"))) - - types = _event_types(all_events) - - # Verify order of lifecycle boundaries - assert types.index(AgentRunEvent.REASONING_STARTED) < types.index(AgentRunEvent.REASONING_COMPLETED) - # First text block: opened then closed by tool_call_started - first_text_start = types.index(AgentRunEvent.TEXT_MESSAGE_STARTED) - first_text_end = types.index(AgentRunEvent.TEXT_MESSAGE_COMPLETED) - assert first_text_start < first_text_end - assert first_text_end < types.index(AgentRunEvent.TOOL_CALL_STARTED) - # Second text block: opened, closed by run_completed - second_text_start = types.index(AgentRunEvent.TEXT_MESSAGE_STARTED, first_text_start + 1) - second_text_end = types.index(AgentRunEvent.TEXT_MESSAGE_COMPLETED, first_text_end + 1) - assert second_text_start < second_text_end - assert second_text_end < types.index(AgentRunEvent.RUN_COMPLETED) - - def test_reasoning_step_to_tool_to_text(self): - """ReasoningTools step → tool call → text answer.""" - adapter = _create_adapter() - all_events = [] - - # reasoning_step (auto-opens) - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="analyzing"))) - assert adapter._reasoning_active is True - - # tool_call_started → auto-closes reasoning - all_events.extend( - adapter.to_digitalkin_events(_make_event("ToolCallStarted", tool=_make_tool())) - ) - assert adapter._reasoning_active is False - - # tool completed - all_events.extend( - adapter.to_digitalkin_events( - _make_event("ToolCallCompleted", tool=_make_tool(result="ok"), content="ok") - ) - ) - - # text answer - all_events.extend( - adapter.to_digitalkin_events(_make_event("RunContent", content="result", reasoning_content=None)) - ) - - types = _event_types(all_events) - # reasoning auto-opened, then auto-closed by tool - assert types[0] == AgentRunEvent.REASONING_STARTED - assert types[1] == AgentRunEvent.REASONING_STEP - assert types[2] == AgentRunEvent.REASONING_COMPLETED - assert types[3] == AgentRunEvent.TOOL_CALL_STARTED - - def test_content_to_reasoning_via_run_content(self): - """run_content with reasoning_content after text → close text, open reasoning.""" - adapter = _create_adapter() - all_events = [] - - # Text first - all_events.extend( - adapter.to_digitalkin_events(_make_event("RunContent", content="hello", reasoning_content=None)) - ) - assert adapter._content_active is True - - # Now reasoning via run_content - all_events.extend( - adapter.to_digitalkin_events(_make_event("RunContent", content=None, reasoning_content="deep thought")) - ) - - types = _event_types(all_events) - assert AgentRunEvent.TEXT_MESSAGE_COMPLETED in types - assert AgentRunEvent.REASONING_STARTED in types - assert types.index(AgentRunEvent.TEXT_MESSAGE_COMPLETED) < types.index(AgentRunEvent.REASONING_STARTED) - - def test_reasoning_to_reasoning_step_shares_lifecycle(self): - """If native reasoning is active, reasoning_step reuses the open lifecycle.""" - adapter = _create_adapter() - all_events = [] - - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStarted"))) - reasoning_id = adapter._current_reasoning_id - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="more"))) - - types = _event_types(all_events) - # No second REASONING_STARTED - assert types.count(AgentRunEvent.REASONING_STARTED) == 1 - # Step uses the same reasoning_id - step_event = [e for e in all_events if hasattr(e, "delta") and getattr(e, "delta", None) == "more"][0] - assert step_event.reasoning_id == reasoning_id - - def test_run_paused_after_reasoning_step(self): - """reasoning_step then run_paused: reasoning closes before synthesized tools.""" - adapter = _create_adapter() - all_events = [] - - all_events.extend(adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="plan"))) - assert adapter._reasoning_active is True - - tool = _make_tool(tool_call_id="ext1", tool_name="get_weather", external_execution_required=True) - all_events.extend(adapter.to_digitalkin_events(_make_event("RunPaused", tools=[tool], requirements=[]))) - - types = _event_types(all_events) - assert AgentRunEvent.REASONING_COMPLETED in types - assert types.index(AgentRunEvent.REASONING_COMPLETED) < types.index(AgentRunEvent.TOOL_CALL_STARTED) - assert adapter.is_paused is True - assert adapter._reasoning_active is False - - -# ═══════════════════════════════════════════════════════════════════════════ -# 8. FLUSH -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestFlush: - def test_flush_closes_active_content(self): - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("RunContent", content="hello", reasoning_content=None)) - events = adapter.flush() - types = _event_types(events) - assert types == [AgentRunEvent.TEXT_MESSAGE_COMPLETED] - assert adapter._content_active is False - - def test_flush_closes_active_reasoning(self): - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("ReasoningStarted")) - events = adapter.flush() - types = _event_types(events) - assert types == [AgentRunEvent.REASONING_COMPLETED] - assert adapter._reasoning_active is False - - def test_flush_closes_reasoning_step_auto_opened(self): - """Flush after auto-opened reasoning_step closes the reasoning.""" - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("ReasoningStep", reasoning_content="step")) - assert adapter._reasoning_active is True - events = adapter.flush() - types = _event_types(events) - assert types == [AgentRunEvent.REASONING_COMPLETED] - - def test_flush_closes_both_content_and_reasoning(self): - """Edge case: if somehow both are active, flush closes both.""" - adapter = _create_adapter() - # Force both active (shouldn't happen normally, but test robustness) - adapter._content_active = True - adapter._current_message_id = "m1" - adapter._reasoning_active = True - adapter._current_reasoning_id = "r1" - - events = adapter.flush() - types = _event_types(events) - assert AgentRunEvent.TEXT_MESSAGE_COMPLETED in types - assert AgentRunEvent.REASONING_COMPLETED in types - - def test_flush_empty_when_nothing_active(self): - adapter = _create_adapter() - events = adapter.flush() - assert events == [] - - def test_double_flush_idempotent(self): - adapter = _create_adapter() - adapter.to_digitalkin_events(_make_event("RunContent", content="hello", reasoning_content=None)) - adapter.flush() - events = adapter.flush() - assert events == [] - - -# ═══════════════════════════════════════════════════════════════════════════ -# 9. FULL REALISTIC SEQUENCES -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestRealisticSequences: - def test_ada_sequence_think_search_analyze_text_pause(self): - """Realistic Ada sequence: think → search → analyze → text → pause on frontend tool.""" - adapter = _create_adapter() - all_events = [] - - # 1. think tool call - all_events.extend(adapter.to_digitalkin_events( - _make_event("ToolCallStarted", tool=_make_tool("tc-think", "think")) - )) - all_events.extend(adapter.to_digitalkin_events( - _make_event("ToolCallCompleted", tool=_make_tool("tc-think", "think", result="planned"), content="planned") - )) - - # 2. reasoning_step after think (auto-wraps) - all_events.extend(adapter.to_digitalkin_events( - _make_event("ReasoningStep", reasoning_content="## Plan\nI'll search first") - )) - - # 3. text message - all_events.extend(adapter.to_digitalkin_events( - _make_event("RunContent", content="Searching...", reasoning_content=None) - )) - - # 4. search tool - all_events.extend(adapter.to_digitalkin_events( - _make_event("ToolCallStarted", tool=_make_tool("tc-search", "web_search")) - )) - all_events.extend(adapter.to_digitalkin_events( - _make_event("ToolCallCompleted", tool=_make_tool("tc-search", "web_search", result="results"), content="results") - )) - - # 5. analyze tool - all_events.extend(adapter.to_digitalkin_events( - _make_event("ToolCallStarted", tool=_make_tool("tc-analyze", "analyze")) - )) - all_events.extend(adapter.to_digitalkin_events( - _make_event("ToolCallCompleted", tool=_make_tool("tc-analyze", "analyze", result="analysis"), content="analysis") - )) - - # 6. reasoning_step after analyze (auto-wraps again — new lifecycle) - all_events.extend(adapter.to_digitalkin_events( - _make_event("ReasoningStep", reasoning_content="## Analysis\nResults look good") - )) - - # 7. text answer - all_events.extend(adapter.to_digitalkin_events( - _make_event("RunContent", content="Here are the results!", reasoning_content=None) - )) - - # 8. frontend tool pause — RunPausedEvent.tools contains ALL tools - # (backend ones already executed + external ones). The adapter must - # only synthesize events for external ones. - backend_tool = _make_tool("tc-think", "think") # already executed, NOT external - ext_tool = _make_tool("ext-show", "show_sources", external_execution_required=True) - all_events.extend(adapter.to_digitalkin_events( - _make_event("RunPaused", tools=[backend_tool, ext_tool], requirements=[]) - )) - - types = _event_types(all_events) - - # Verify no orphan reasoning events - reasoning_starts = [i for i, t in enumerate(types) if t == AgentRunEvent.REASONING_STARTED] - reasoning_ends = [i for i, t in enumerate(types) if t == AgentRunEvent.REASONING_COMPLETED] - assert len(reasoning_starts) == len(reasoning_ends), ( - f"Mismatched reasoning lifecycle: {len(reasoning_starts)} starts vs {len(reasoning_ends)} ends" - ) - for start, end in zip(reasoning_starts, reasoning_ends): - assert start < end, "REASONING_COMPLETED before REASONING_STARTED" - - # Verify no orphan text events - text_starts = [i for i, t in enumerate(types) if t == AgentRunEvent.TEXT_MESSAGE_STARTED] - text_ends = [i for i, t in enumerate(types) if t == AgentRunEvent.TEXT_MESSAGE_COMPLETED] - assert len(text_starts) == len(text_ends), ( - f"Mismatched text lifecycle: {len(text_starts)} starts vs {len(text_ends)} ends" - ) - - # Verify pause at the end - assert adapter.is_paused is True - ext_tools = [t for t in adapter.paused_tool_executions if getattr(t, "external_execution_required", False)] - assert len(ext_tools) == 1 - assert ext_tools[0].tool_name == "show_sources" - - def test_template_archetype_simple_pause_resume_style(self): - """Simple template-archetype sequence: reasoning → text → external tool → pause.""" - adapter = _create_adapter() - all_events = [] - - # Native reasoning - all_events.extend(adapter.to_digitalkin_events(_make_event("RunStarted", run_id="r1", thread_id="t1"))) - all_events.extend(adapter.to_digitalkin_events( - _make_event("RunContent", content=None, reasoning_content="Let me check the weather") - )) - all_events.extend(adapter.to_digitalkin_events( - _make_event("RunContent", content=None, reasoning_content="") # close reasoning - )) - - # Text - all_events.extend(adapter.to_digitalkin_events( - _make_event("RunContent", content="Sure! Let me check.", reasoning_content=None) - )) - - # External tool pause - ext_tool = _make_tool("ext1", "get_weather", {"city": "Lyon"}, external_execution_required=True) - all_events.extend(adapter.to_digitalkin_events( - _make_event("RunPaused", tools=[ext_tool], requirements=[]) - )) - - # Flush - all_events.extend(adapter.flush()) - - types = _event_types(all_events) - - # All lifecycles properly closed - assert types.count(AgentRunEvent.REASONING_STARTED) == types.count(AgentRunEvent.REASONING_COMPLETED) - assert types.count(AgentRunEvent.TEXT_MESSAGE_STARTED) == types.count(AgentRunEvent.TEXT_MESSAGE_COMPLETED) - assert adapter.is_paused is True - - def test_run_paused_mixed_backend_and_frontend_tools(self): - """RunPausedEvent.tools has backend tools (think) + 2 frontend tools. - - The adapter must only synthesize events for the 2 external tools, - skip the backend tool (already streamed), and deduplicate if the - same tool_call_id appears multiple times (Agno accumulation bug). - """ - adapter = _create_adapter() - - backend = _make_tool("tc-think", "think") # NOT external - ext1 = _make_tool("ext-ask", "ask_question", external_execution_required=True) - ext2 = _make_tool("ext-map", "show_map", external_execution_required=True) - # Simulate Agno's accumulation: ext1 appears twice (from two yield batches) - ext1_dup = _make_tool("ext-ask", "ask_question", external_execution_required=True) - - events = adapter.to_digitalkin_events( - _make_event("RunPaused", tools=[backend, ext1, ext1_dup, ext2], requirements=[]) - ) - types = _event_types(events) - - # Only 2 unique external tools → 2 pairs of start/completed - assert types == [ - AgentRunEvent.TOOL_CALL_STARTED, - AgentRunEvent.TOOL_CALL_COMPLETED, - AgentRunEvent.TOOL_CALL_STARTED, - AgentRunEvent.TOOL_CALL_COMPLETED, - ] - # First pair is ask_question, second is show_map - assert events[0].tool.tool_name == "ask_question" - assert events[2].tool.tool_name == "show_map" - # Backend tool NOT synthesized - tool_names = [e.tool.tool_name for e in events if hasattr(e, "tool") and e.tool] - assert "think" not in tool_names - - def test_run_paused_no_external_tools_emits_nothing(self): - """If RunPausedEvent.tools only has backend tools, no events synthesized.""" - adapter = _create_adapter() - backend = _make_tool("tc-think", "think") # NOT external - events = adapter.to_digitalkin_events( - _make_event("RunPaused", tools=[backend], requirements=[]) - ) - # No tool events (think is not external) - tool_events = [e for e in events if AgentRunEvent.TOOL_CALL_STARTED == e.event or AgentRunEvent.TOOL_CALL_COMPLETED == e.event] - assert tool_events == [] - # But adapter is still paused - assert adapter.is_paused is True diff --git a/tests/mixins/test_agui_mixin.py b/tests/mixins/test_agui_mixin.py new file mode 100644 index 00000000..9e013c51 --- /dev/null +++ b/tests/mixins/test_agui_mixin.py @@ -0,0 +1,219 @@ +"""Tests for ``AgUiMixin`` AG-UI run_id / thread_id propagation. + +The mixin is the single point where two distinct identifiers meet: + +* the **AG-UI client** ULID, primed by the trigger from ``RunAgentInput``; +* the **agno** UUID4, attached by the adapter to every wrapped event. + +The AG-UI protocol requires that ``RUN_FINISHED`` echoes the same ``run_id`` as +``RUN_STARTED``. These tests pin the resolution policy: + +#. If the trigger primed ``self._run_id`` (or ``self._thread_id``), keep it — + never let an incoming event overwrite it. +#. Otherwise fall back to ``event.run_id`` / ``event.thread_id``. +#. If both are empty, mint a fresh ``uuid4``. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from digitalkin.mixins.agui_mixin import AgUiMixin +from digitalkin.models.events import ( + AgentRunEvent, + RunCompletedEvent, + RunStartedEvent, +) +from digitalkin.models.module.ag_ui import AgUiRunFinishedOutput, AgUiRunStartedOutput + +CLIENT_THREAD_ID = "missions:01kpwyz1xm5t0xc847mwkr1tm0" +CLIENT_RUN_ID = "01kpwyz3xpncrkccnsa5a9g5fh" +AGNO_RUN_ID = "97882532-f1df-4ada-bde3-c760f8be8e13" +AGNO_THREAD_ID = "agno-session-id" + + +def _make_context() -> MagicMock: + """Mock ``ModuleContext`` with the bits the mixin reads.""" + ctx = MagicMock() + ctx.callbacks = MagicMock() + ctx.callbacks.send_message = AsyncMock() + ctx.callbacks.logger = MagicMock() + ctx.session = MagicMock() + ctx.session.current_ids = MagicMock(return_value={}) + return ctx + + +def _emitted_event(ctx: MagicMock, expected_cls: type) -> Any: + """Pull the last AG-UI event sent through ``send_message``.""" + assert ctx.callbacks.send_message.await_count >= 1, "send_message was never awaited" + output = ctx.callbacks.send_message.await_args_list[-1].args[0] + assert isinstance(output.root, expected_cls), f"expected {expected_cls.__name__}, got {type(output.root).__name__}" + return output.root.event + + +def _started(*, run_id: str | None, thread_id: str | None) -> RunStartedEvent: + """Build a ``RunStartedEvent`` with explicit Nones for pyright friendliness.""" + return RunStartedEvent( + event=AgentRunEvent.RUN_STARTED, + run_id=run_id, + thread_id=thread_id, + timestamp=None, + metadata=None, + ) + + +def _completed(*, run_id: str | None) -> RunCompletedEvent: + """Build a ``RunCompletedEvent`` with explicit Nones.""" + return RunCompletedEvent( + event=AgentRunEvent.RUN_COMPLETED, + run_id=run_id, + timestamp=None, + metadata=None, + final_content=None, + usage=None, + message_id=None, + ) + + +class TestRunStartedResolution: + """``_handle_run_started`` must honour a primed ``_run_id`` / ``_thread_id``.""" + + @pytest.mark.asyncio + async def test_primed_run_id_survives_agno_event(self) -> None: + """Trigger primes ULID; event carries agno UUID4 → emit ULID.""" + mixin = AgUiMixin() + mixin._thread_id = CLIENT_THREAD_ID + mixin._run_id = CLIENT_RUN_ID + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=AGNO_RUN_ID, thread_id=AGNO_THREAD_ID)) + + emitted = _emitted_event(ctx, AgUiRunStartedOutput) + assert emitted.run_id == CLIENT_RUN_ID + assert emitted.thread_id == CLIENT_THREAD_ID + assert mixin._run_id == CLIENT_RUN_ID + assert mixin._thread_id == CLIENT_THREAD_ID + + @pytest.mark.asyncio + async def test_unprimed_falls_back_to_event_ids(self) -> None: + """No prime → event ids fill in (Ada-style flow).""" + mixin = AgUiMixin() + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=AGNO_RUN_ID, thread_id=AGNO_THREAD_ID)) + + emitted = _emitted_event(ctx, AgUiRunStartedOutput) + assert emitted.run_id == AGNO_RUN_ID + assert emitted.thread_id == AGNO_THREAD_ID + assert mixin._run_id == AGNO_RUN_ID + assert mixin._thread_id == AGNO_THREAD_ID + + @pytest.mark.asyncio + async def test_no_ids_anywhere_mints_uuid4(self) -> None: + """Empty prime AND empty event ids → fresh uuid4 fallback.""" + mixin = AgUiMixin() + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=None, thread_id=None)) + + emitted = _emitted_event(ctx, AgUiRunStartedOutput) + assert emitted.run_id, "run_id must not be empty" + assert emitted.thread_id, "thread_id must not be empty" + assert mixin._run_id == emitted.run_id + assert mixin._thread_id == emitted.thread_id + + +class TestRunCompletedResolution: + """``_handle_run_completed`` must echo whatever ``RUN_STARTED`` emitted.""" + + @pytest.mark.asyncio + async def test_primed_run_id_used_in_run_finished(self) -> None: + """Regression: agno UUID4 must NOT win over the primed ULID. + + This is the bug that caused ``RUN_STARTED`` to carry the AG-UI ULID + while ``RUN_FINISHED`` carried agno's UUID4 — the client could not + correlate the closure and the run looked orphaned. + """ + mixin = AgUiMixin() + mixin._thread_id = CLIENT_THREAD_ID + mixin._run_id = CLIENT_RUN_ID + ctx = _make_context() + + await mixin._handle_run_completed(ctx, _completed(run_id=AGNO_RUN_ID)) + + emitted = _emitted_event(ctx, AgUiRunFinishedOutput) + assert emitted.run_id == CLIENT_RUN_ID + assert emitted.thread_id == CLIENT_THREAD_ID + + @pytest.mark.asyncio + async def test_unprimed_falls_back_to_event_run_id(self) -> None: + """Without a prime, the agno run_id is the only id available.""" + mixin = AgUiMixin() + ctx = _make_context() + + await mixin._handle_run_completed(ctx, _completed(run_id=AGNO_RUN_ID)) + + emitted = _emitted_event(ctx, AgUiRunFinishedOutput) + assert emitted.run_id == AGNO_RUN_ID + + @pytest.mark.asyncio + async def test_no_ids_anywhere_mints_uuid4(self) -> None: + """Defensive fallback — never emit an empty run_id.""" + mixin = AgUiMixin() + ctx = _make_context() + + await mixin._handle_run_completed(ctx, _completed(run_id=None)) + + emitted = _emitted_event(ctx, AgUiRunFinishedOutput) + assert emitted.run_id, "run_id must not be empty" + + +class TestEndToEndConsistency: + """``RUN_STARTED`` and ``RUN_FINISHED`` must always carry the same ids.""" + + @pytest.mark.asyncio + async def test_primed_ids_match_through_full_lifecycle(self) -> None: + """Trigger primes → start emits ULID → finish emits same ULID.""" + mixin = AgUiMixin() + mixin._thread_id = CLIENT_THREAD_ID + mixin._run_id = CLIENT_RUN_ID + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=AGNO_RUN_ID, thread_id=AGNO_THREAD_ID)) + await mixin._handle_run_completed(ctx, _completed(run_id=AGNO_RUN_ID)) + + started = ctx.callbacks.send_message.await_args_list[0].args[0].root.event + finished = ctx.callbacks.send_message.await_args_list[1].args[0].root.event + assert started.run_id == finished.run_id == CLIENT_RUN_ID + assert started.thread_id == finished.thread_id == CLIENT_THREAD_ID + + @pytest.mark.asyncio + async def test_unprimed_ids_match_through_full_lifecycle(self) -> None: + """No prime → start adopts agno ids → finish echoes them.""" + mixin = AgUiMixin() + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=AGNO_RUN_ID, thread_id=AGNO_THREAD_ID)) + await mixin._handle_run_completed(ctx, _completed(run_id=AGNO_RUN_ID)) + + started = ctx.callbacks.send_message.await_args_list[0].args[0].root.event + finished = ctx.callbacks.send_message.await_args_list[1].args[0].root.event + assert started.run_id == finished.run_id == AGNO_RUN_ID + assert started.thread_id == finished.thread_id == AGNO_THREAD_ID + + @pytest.mark.asyncio + async def test_run_started_does_not_overwrite_existing_run_id(self) -> None: + """A second RUN_STARTED in the same stream must not clobber state.""" + mixin = AgUiMixin() + mixin._run_id = CLIENT_RUN_ID + mixin._thread_id = CLIENT_THREAD_ID + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=AGNO_RUN_ID, thread_id=AGNO_THREAD_ID)) + await mixin._handle_run_started(ctx, _started(run_id="another-spurious-id", thread_id="another-thread")) + + assert mixin._run_id == CLIENT_RUN_ID + assert mixin._thread_id == CLIENT_THREAD_ID diff --git a/uv.lock b/uv.lock index a6f22d8e..c9f89002 100644 --- a/uv.lock +++ b/uv.lock @@ -883,7 +883,6 @@ dev = [ { name = "cryptography" }, { name = "mypy" }, { name = "pre-commit" }, - { name = "pyright" }, { name = "ruff" }, { name = "twine" }, { name = "types-grpcio" }, @@ -954,10 +953,9 @@ dev = [ { name = "build", specifier = "==1.4.2" }, { name = "bump-my-version", specifier = "==1.2.7" }, { name = "cryptography", specifier = "==46.0.6" }, - { name = "mypy", specifier = "==1.19.1" }, + { name = "mypy", specifier = "==1.20.2" }, { name = "pre-commit", specifier = "==4.5.1" }, - { name = "pyright", specifier = "==1.1.408" }, - { name = "ruff", specifier = "==0.15.8" }, + { name = "ruff", specifier = "==0.15.11" }, { name = "twine", specifier = "==6.2.0" }, { name = "types-grpcio", specifier = "==1.0.0.20251009" }, { name = "types-grpcio-health-checking", specifier = "==1.0.0.20250506" }, @@ -2561,7 +2559,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.1" +version = "1.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -2570,39 +2568,51 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, + { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, ] [[package]] @@ -3391,19 +3401,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] -[[package]] -name = "pyright" -version = "1.1.408" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, -] - [[package]] name = "pytest" version = "9.0.2" @@ -3737,27 +3734,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, - { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, - { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, - { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, - { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, - { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, - { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, - { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] [[package]]