"""RunState class for serializing and resuming agent runs with human-in-the-loop support."""

from __future__ import annotations

import copy
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Generic, Optional, cast

from openai.types.responses import (
    ResponseComputerToolCall,
    ResponseFunctionToolCall,
    ResponseOutputMessage,
    ResponseReasoningItem,
)
from openai.types.responses.response_input_param import (
    ComputerCallOutput,
    FunctionCallOutput,
    LocalShellCallOutput,
    McpApprovalResponse,
)
from openai.types.responses.response_output_item import (
    LocalShellCall,
    McpApprovalRequest,
    McpListTools,
)
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
from pydantic import TypeAdapter, ValidationError
from typing_extensions import TypeVar

from .exceptions import UserError
from .handoffs import Handoff
from .items import (
    HandoffCallItem,
    HandoffOutputItem,
    MCPApprovalRequestItem,
    MCPApprovalResponseItem,
    MCPListToolsItem,
    MessageOutputItem,
    ModelResponse,
    ReasoningItem,
    RunItem,
    ToolApprovalItem,
    ToolCallItem,
    ToolCallOutputItem,
    TResponseInputItem,
    normalize_function_call_output_payload,
)
from .logger import logger
from .run_context import RunContextWrapper
from .tool import ApplyPatchTool, ComputerTool, FunctionTool, HostedMCPTool, ShellTool
from .usage import RequestUsage, Usage

if TYPE_CHECKING:
    from ._run_impl import (
        NextStepInterruption,
        ProcessedResponse,
    )
    from .agent import Agent
    from .guardrail import InputGuardrailResult, OutputGuardrailResult
    from .items import ModelResponse, RunItem

TContext = TypeVar("TContext", default=Any)
TAgent = TypeVar("TAgent", bound="Agent[Any]", default="Agent[Any]")

# Schema version for serialization compatibility
CURRENT_SCHEMA_VERSION = "1.0"


@dataclass
class RunState(Generic[TContext, TAgent]):
    """Serializable snapshot of an agent's run, including context, usage, and interruptions.

    This class allows you to:
    1. Pause an agent run when tools need approval
    2. Serialize the run state to JSON
    3. Approve or reject tool calls
    4. Resume the run from where it left off

    While this class has publicly writable properties (prefixed with `_`), they are not meant to be
    used directly. To read these properties, use the `RunResult` instead.

    Manipulation of the state directly can lead to unexpected behavior and should be avoided.
    Instead, use the `approve()` and `reject()` methods to interact with the state.
    """

    _current_turn: int = 0
    """Current turn number in the conversation."""

    _current_agent: TAgent | None = None
    """The agent currently handling the conversation."""

    _original_input: str | list[Any] = field(default_factory=list)
    """Original user input prior to any processing."""

    _model_responses: list[ModelResponse] = field(default_factory=list)
    """Responses from the model so far."""

    _context: RunContextWrapper[TContext] | None = None
    """Run context tracking approvals, usage, and other metadata."""

    _generated_items: list[RunItem] = field(default_factory=list)
    """Items generated by the agent during the run."""

    _max_turns: int = 10
    """Maximum allowed turns before forcing termination."""

    _input_guardrail_results: list[InputGuardrailResult] = field(default_factory=list)
    """Results from input guardrails applied to the run."""

    _output_guardrail_results: list[OutputGuardrailResult] = field(default_factory=list)
    """Results from output guardrails applied to the run."""

    _current_step: NextStepInterruption | None = None
    """Current step if the run is interrupted (e.g., for tool approval)."""

    _last_processed_response: ProcessedResponse | None = None
    """The last processed model response. This is needed for resuming from interruptions."""

    _current_turn_persisted_item_count: int = 0
    """Tracks how many generated run items from this turn were already written to the session.
    When a turn is interrupted (e.g., awaiting tool approval) and later resumed, we rewind the
    counter before continuing so the pending tool output still gets stored.
    """

    _tool_use_tracker_snapshot: dict[str, list[str]] = field(default_factory=dict)
    """Serialized snapshot of the AgentToolUseTracker (agent name -> tools used)."""

    def __init__(
        self,
        context: RunContextWrapper[TContext],
        original_input: str | list[Any],
        starting_agent: TAgent,
        max_turns: int = 10,
    ):
        """Initialize a new RunState.

        Args:
            context: The run context wrapper.
            original_input: The original input to the agent.
            starting_agent: The agent to start the run with.
            max_turns: Maximum number of turns allowed.
        """
        self._context = context
        self._original_input = _clone_original_input(original_input)
        self._current_agent = starting_agent
        self._max_turns = max_turns
        self._model_responses = []
        self._generated_items = []
        self._input_guardrail_results = []
        self._output_guardrail_results = []
        self._current_step = None
        self._current_turn = 0
        self._last_processed_response = None
        self._current_turn_persisted_item_count = 0
        self._tool_use_tracker_snapshot = {}

    def get_interruptions(self) -> list[RunItem]:
        """Returns all interruptions if the current step is an interruption.

        Returns:
            List of tool approval items awaiting approval, or empty list if no interruptions.
        """
        # Import at runtime to avoid circular import
        from ._run_impl import NextStepInterruption

        if self._current_step is None or not isinstance(self._current_step, NextStepInterruption):
            return []
        return self._current_step.interruptions

    def approve(self, approval_item: ToolApprovalItem, always_approve: bool = False) -> None:
        """Approves a tool call requested by the agent through an interruption.

        To approve the request, use this method and then run the agent again with the same state
        object to continue the execution.

        By default it will only approve the current tool call. To allow the tool to be used
        multiple times throughout the run, set `always_approve` to True.

        Args:
            approval_item: The tool call approval item to approve.
            always_approve: If True, always approve this tool (for all future calls).
        """
        if self._context is None:
            raise UserError("Cannot approve tool: RunState has no context")
        self._context.approve_tool(approval_item, always_approve=always_approve)

    def reject(self, approval_item: ToolApprovalItem, always_reject: bool = False) -> None:
        """Rejects a tool call requested by the agent through an interruption.

        To reject the request, use this method and then run the agent again with the same state
        object to continue the execution.

        By default it will only reject the current tool call. To prevent the tool from being
        used throughout the run, set `always_reject` to True.

        Args:
            approval_item: The tool call approval item to reject.
            always_reject: If True, always reject this tool (for all future calls).
        """
        if self._context is None:
            raise UserError("Cannot reject tool: RunState has no context")
        self._context.reject_tool(approval_item, always_reject=always_reject)

    @staticmethod
    def _camelize_field_names(data: dict[str, Any] | list[Any] | Any) -> Any:
        """Convert snake_case field names to camelCase for JSON serialization.

        This function converts common field names from Python's snake_case convention
        to JSON's camelCase convention.

        Args:
            data: Dictionary, list, or value with potentially snake_case field names.

        Returns:
            Dictionary, list, or value with normalized camelCase field names.
        """
        if isinstance(data, dict):
            camelized: dict[str, Any] = {}
            field_mapping = {
                "call_id": "callId",
                "response_id": "responseId",
                "provider_data": "providerData",
            }

            for key, value in data.items():
                # Convert snake_case to camelCase
                camelized_key = field_mapping.get(key, key)

                # Recursively camelize nested dictionaries and lists
                if isinstance(value, dict):
                    camelized[camelized_key] = RunState._camelize_field_names(value)
                elif isinstance(value, list):
                    camelized[camelized_key] = [
                        RunState._camelize_field_names(item)
                        if isinstance(item, (dict, list))
                        else item
                        for item in value
                    ]
                else:
                    camelized[camelized_key] = value

            return camelized
        elif isinstance(data, list):
            return [
                RunState._camelize_field_names(item) if isinstance(item, (dict, list)) else item
                for item in data
            ]
        else:
            return data

    def to_json(self) -> dict[str, Any]:
        """Serializes the run state to a JSON-compatible dictionary.

        This method is used to serialize the run state to a dictionary that can be used to
        resume the run later.

        Returns:
            A dictionary representation of the run state.

        Raises:
            UserError: If required state (agent, context) is missing.
        """
        if self._current_agent is None:
            raise UserError("Cannot serialize RunState: No current agent")
        if self._context is None:
            raise UserError("Cannot serialize RunState: No context")

        # Serialize approval records
        approvals_dict: dict[str, dict[str, Any]] = {}
        for tool_name, record in self._context._approvals.items():
            approvals_dict[tool_name] = {
                "approved": record.approved
                if isinstance(record.approved, bool)
                else list(record.approved),
                "rejected": record.rejected
                if isinstance(record.rejected, bool)
                else list(record.rejected),
            }

        # Serialize model responses with camelCase field names
        model_responses = []
        for resp in self._model_responses:
            response_dict = {
                "usage": {
                    "requests": resp.usage.requests,
                    "inputTokens": resp.usage.input_tokens,
                    "inputTokensDetails": [
                        resp.usage.input_tokens_details.model_dump()
                        if hasattr(resp.usage.input_tokens_details, "model_dump")
                        else {}
                    ],
                    "outputTokens": resp.usage.output_tokens,
                    "outputTokensDetails": [
                        resp.usage.output_tokens_details.model_dump()
                        if hasattr(resp.usage.output_tokens_details, "model_dump")
                        else {}
                    ],
                    "totalTokens": resp.usage.total_tokens,
                    "requestUsageEntries": [
                        {
                            "inputTokens": entry.input_tokens,
                            "outputTokens": entry.output_tokens,
                            "totalTokens": entry.total_tokens,
                            "inputTokensDetails": (
                                entry.input_tokens_details.model_dump()
                                if hasattr(entry.input_tokens_details, "model_dump")
                                else {}
                            ),
                            "outputTokensDetails": (
                                entry.output_tokens_details.model_dump()
                                if hasattr(entry.output_tokens_details, "model_dump")
                                else {}
                            ),
                        }
                        for entry in resp.usage.request_usage_entries
                    ],
                },
                "output": [
                    self._camelize_field_names(item.model_dump(exclude_unset=True))
                    for item in resp.output
                ],
                "responseId": resp.response_id,
            }
            model_responses.append(response_dict)

        # Normalize and camelize originalInput if it's a list of items
        # Convert API format to protocol format
        # Protocol expects function_call_result (not function_call_output)
        original_input_serialized = self._original_input
        if isinstance(original_input_serialized, list):
            normalized_items = []
            for item in original_input_serialized:
                if isinstance(item, dict):
                    # Create a copy to avoid modifying the original
                    normalized_item = dict(item)
                    # Convert API format to protocol format
                    # API uses function_call_output, protocol uses function_call_result
                    item_type = normalized_item.get("type")
                    call_id = normalized_item.get("call_id") or normalized_item.get("callId")
                    if item_type == "function_call_output":
                        # Convert to protocol format: function_call_result
                        normalized_item["type"] = "function_call_result"
                        # Protocol format requires status field (default to 'completed')
                        if "status" not in normalized_item:
                            normalized_item["status"] = "completed"
                        # Protocol format requires name field
                        # Look it up from the corresponding function_call if missing
                        if "name" not in normalized_item and call_id:
                            normalized_item["name"] = self._lookup_function_name(call_id)
                    # Convert assistant messages with string content to array format
                    # Protocol requires content to be an array for assistant messages
                    role = normalized_item.get("role")
                    if role == "assistant":
                        content = normalized_item.get("content")
                        if isinstance(content, str):
                            # Convert string content to array format with output_text
                            normalized_item["content"] = [{"type": "output_text", "text": content}]
                        # Ensure status field is present (required by protocol schema)
                        if "status" not in normalized_item:
                            normalized_item["status"] = "completed"
                    # Normalize field names to camelCase for JSON (call_id -> callId)
                    normalized_item = self._camelize_field_names(normalized_item)
                    normalized_items.append(normalized_item)
                else:
                    normalized_items.append(item)
            original_input_serialized = normalized_items

        result = {
            "$schemaVersion": CURRENT_SCHEMA_VERSION,
            "currentTurn": self._current_turn,
            "currentAgent": {
                "name": self._current_agent.name,
            },
            "originalInput": original_input_serialized,
            "modelResponses": model_responses,
            "context": {
                "usage": {
                    "requests": self._context.usage.requests,
                    "inputTokens": self._context.usage.input_tokens,
                    "inputTokensDetails": [
                        self._context.usage.input_tokens_details.model_dump()
                        if hasattr(self._context.usage.input_tokens_details, "model_dump")
                        else {}
                    ],
                    "outputTokens": self._context.usage.output_tokens,
                    "outputTokensDetails": [
                        self._context.usage.output_tokens_details.model_dump()
                        if hasattr(self._context.usage.output_tokens_details, "model_dump")
                        else {}
                    ],
                    "totalTokens": self._context.usage.total_tokens,
                    "requestUsageEntries": [
                        {
                            "inputTokens": entry.input_tokens,
                            "outputTokens": entry.output_tokens,
                            "totalTokens": entry.total_tokens,
                            "inputTokensDetails": (
                                entry.input_tokens_details.model_dump()
                                if hasattr(entry.input_tokens_details, "model_dump")
                                else {}
                            ),
                            "outputTokensDetails": (
                                entry.output_tokens_details.model_dump()
                                if hasattr(entry.output_tokens_details, "model_dump")
                                else {}
                            ),
                        }
                        for entry in self._context.usage.request_usage_entries
                    ],
                },
                "approvals": approvals_dict,
                "context": self._context.context
                if isinstance(self._context.context, dict)
                else (
                    self._context.context.__dict__
                    if hasattr(self._context.context, "__dict__")
                    else {}
                ),
            },
            "toolUseTracker": copy.deepcopy(self._tool_use_tracker_snapshot),
            "maxTurns": self._max_turns,
            "noActiveAgentRun": True,
            "inputGuardrailResults": [
                {
                    "guardrail": {"type": "input", "name": result.guardrail.name},
                    "output": {
                        "tripwireTriggered": result.output.tripwire_triggered,
                        "outputInfo": result.output.output_info,
                    },
                }
                for result in self._input_guardrail_results
            ],
            "outputGuardrailResults": [
                {
                    "guardrail": {"type": "output", "name": result.guardrail.name},
                    "agentOutput": result.agent_output,
                    "agent": {"name": result.agent.name},
                    "output": {
                        "tripwireTriggered": result.output.tripwire_triggered,
                        "outputInfo": result.output.output_info,
                    },
                }
                for result in self._output_guardrail_results
            ],
        }

        # generated_items already contains the latest turn's items.
        # Include lastProcessedResponse.newItems only when they are not
        # already present (by id/type or function call_id) to avoid duplicates.
        generated_items = list(self._generated_items)
        if self._last_processed_response and self._last_processed_response.new_items:
            seen_id_types: set[tuple[str, str]] = set()
            seen_call_ids: set[str] = set()

            def _id_type_call(item: Any) -> tuple[str | None, str | None, str | None]:
                item_id = None
                item_type = None
                call_id = None
                if hasattr(item, "raw_item"):
                    raw = item.raw_item
                    if isinstance(raw, dict):
                        item_id = raw.get("id")
                        item_type = raw.get("type")
                        call_id = raw.get("call_id") or raw.get("callId")
                    else:
                        item_id = getattr(raw, "id", None)
                        item_type = getattr(raw, "type", None)
                        call_id = getattr(raw, "call_id", None)
                if item_id is None and hasattr(item, "id"):
                    item_id = getattr(item, "id", None)
                if item_type is None and hasattr(item, "type"):
                    item_type = getattr(item, "type", None)
                return item_id, item_type, call_id

            for existing in generated_items:
                item_id, item_type, call_id = _id_type_call(existing)
                if item_id and item_type:
                    seen_id_types.add((item_id, item_type))
                if call_id:
                    seen_call_ids.add(call_id)

            for new_item in self._last_processed_response.new_items:
                item_id, item_type, call_id = _id_type_call(new_item)
                if call_id and call_id in seen_call_ids:
                    continue
                if item_id and item_type and (item_id, item_type) in seen_id_types:
                    continue
                if item_id and item_type:
                    seen_id_types.add((item_id, item_type))
                if call_id:
                    seen_call_ids.add(call_id)
                generated_items.append(new_item)
        result["generatedItems"] = [self._serialize_item(item) for item in generated_items]
        result["currentStep"] = self._serialize_current_step()
        result["lastModelResponse"] = (
            {
                "usage": {
                    "requests": self._model_responses[-1].usage.requests,
                    "inputTokens": self._model_responses[-1].usage.input_tokens,
                    "inputTokensDetails": [
                        self._model_responses[-1].usage.input_tokens_details.model_dump()
                        if hasattr(
                            self._model_responses[-1].usage.input_tokens_details, "model_dump"
                        )
                        else {}
                    ],
                    "outputTokens": self._model_responses[-1].usage.output_tokens,
                    "outputTokensDetails": [
                        self._model_responses[-1].usage.output_tokens_details.model_dump()
                        if hasattr(
                            self._model_responses[-1].usage.output_tokens_details, "model_dump"
                        )
                        else {}
                    ],
                    "totalTokens": self._model_responses[-1].usage.total_tokens,
                },
                "output": [
                    self._camelize_field_names(item.model_dump(exclude_unset=True))
                    for item in self._model_responses[-1].output
                ],
                "responseId": self._model_responses[-1].response_id,
            }
            if self._model_responses
            else None
        )
        result["lastProcessedResponse"] = (
            self._serialize_processed_response(self._last_processed_response)
            if self._last_processed_response
            else None
        )
        result["currentTurnPersistedItemCount"] = self._current_turn_persisted_item_count
        result["trace"] = None

        return result

    def _serialize_processed_response(
        self, processed_response: ProcessedResponse
    ) -> dict[str, Any]:
        """Serialize a ProcessedResponse to JSON format.

        Args:
            processed_response: The ProcessedResponse to serialize.

        Returns:
            A dictionary representation of the ProcessedResponse.
        """

        # Serialize handoffs
        handoffs = []
        for handoff in processed_response.handoffs:
            # Serialize handoff - just store the tool_name since we'll look
            # it up during deserialization
            handoff_dict = {
                "toolName": handoff.handoff.tool_name
                if hasattr(handoff.handoff, "tool_name")
                else handoff.handoff.name
                if hasattr(handoff.handoff, "name")
                else None
            }
            handoffs.append(
                {
                    "toolCall": self._camelize_field_names(
                        handoff.tool_call.model_dump(exclude_unset=True)
                        if hasattr(handoff.tool_call, "model_dump")
                        else handoff.tool_call
                    ),
                    "handoff": handoff_dict,
                }
            )

        # Serialize functions
        functions = []
        for func in processed_response.functions:
            # Serialize tool - just store the name since we'll look it up during deserialization
            tool_dict: dict[str, Any] = {"name": func.function_tool.name}
            if hasattr(func.function_tool, "description"):
                tool_dict["description"] = func.function_tool.description
            if hasattr(func.function_tool, "params_json_schema"):
                tool_dict["paramsJsonSchema"] = func.function_tool.params_json_schema
            functions.append(
                {
                    "toolCall": self._camelize_field_names(
                        func.tool_call.model_dump(exclude_unset=True)
                        if hasattr(func.tool_call, "model_dump")
                        else func.tool_call
                    ),
                    "tool": tool_dict,
                }
            )

        # Serialize computer actions
        computer_actions = []
        for action in processed_response.computer_actions:
            # Serialize computer tool - just store the name since we'll look
            # it up during deserialization
            computer_dict = {"name": action.computer_tool.name}
            if hasattr(action.computer_tool, "description"):
                computer_dict["description"] = action.computer_tool.description
            computer_actions.append(
                {
                    "toolCall": self._camelize_field_names(
                        action.tool_call.model_dump(exclude_unset=True)
                        if hasattr(action.tool_call, "model_dump")
                        else action.tool_call
                    ),
                    "computer": computer_dict,
                }
            )

        shell_actions = []
        for shell_action in processed_response.shell_calls:
            shell_dict = {"name": shell_action.shell_tool.name}
            if hasattr(shell_action.shell_tool, "description"):
                shell_dict["description"] = shell_action.shell_tool.description
            shell_actions.append(
                {
                    "toolCall": self._camelize_field_names(
                        shell_action.tool_call.model_dump(exclude_unset=True)
                        if hasattr(shell_action.tool_call, "model_dump")
                        else shell_action.tool_call
                    ),
                    "shell": shell_dict,
                }
            )

        apply_patch_actions = []
        for apply_patch_action in processed_response.apply_patch_calls:
            apply_patch_dict = {"name": apply_patch_action.apply_patch_tool.name}
            if hasattr(apply_patch_action.apply_patch_tool, "description"):
                apply_patch_dict["description"] = apply_patch_action.apply_patch_tool.description
            apply_patch_actions.append(
                {
                    "toolCall": self._camelize_field_names(
                        apply_patch_action.tool_call.model_dump(exclude_unset=True)
                        if hasattr(apply_patch_action.tool_call, "model_dump")
                        else apply_patch_action.tool_call
                    ),
                    "applyPatch": apply_patch_dict,
                }
            )

        # Serialize MCP approval requests
        mcp_approval_requests = []
        for request in processed_response.mcp_approval_requests:
            # request.request_item is a McpApprovalRequest (raw OpenAI type)
            request_item_dict = (
                request.request_item.model_dump(exclude_unset=True)
                if hasattr(request.request_item, "model_dump")
                else request.request_item
            )
            mcp_approval_requests.append(
                {
                    "requestItem": {
                        "rawItem": self._camelize_field_names(request_item_dict),
                    },
                    "mcpTool": request.mcp_tool.to_json()
                    if hasattr(request.mcp_tool, "to_json")
                    else request.mcp_tool,
                }
            )

        return {
            "newItems": [self._serialize_item(item) for item in processed_response.new_items],
            "toolsUsed": processed_response.tools_used,
            "handoffs": handoffs,
            "functions": functions,
            "computerActions": computer_actions,
            "shellActions": shell_actions,
            "applyPatchActions": apply_patch_actions,
            "mcpApprovalRequests": mcp_approval_requests,
        }

    def _serialize_current_step(self) -> dict[str, Any] | None:
        """Serialize the current step if it's an interruption."""
        # Import at runtime to avoid circular import
        from ._run_impl import NextStepInterruption

        if self._current_step is None or not isinstance(self._current_step, NextStepInterruption):
            return None

        # Interruptions are wrapped in a "data" field
        return {
            "type": "next_step_interruption",
            "data": {
                "interruptions": [
                    {
                        "type": "tool_approval_item",
                        "rawItem": self._camelize_field_names(
                            item.raw_item.model_dump(exclude_unset=True)
                            if hasattr(item.raw_item, "model_dump")
                            else item.raw_item
                        ),
                        "agent": {"name": item.agent.name},
                    }
                    for item in self._current_step.interruptions
                    if isinstance(item, ToolApprovalItem)
                ],
            },
        }

    def _serialize_item(self, item: RunItem) -> dict[str, Any]:
        """Serialize a run item to JSON-compatible dict."""
        # Handle model_dump for Pydantic models, dict conversion for TypedDicts
        raw_item_dict: Any
        if hasattr(item.raw_item, "model_dump"):
            raw_item_dict = item.raw_item.model_dump(exclude_unset=True)  # type: ignore
        elif isinstance(item.raw_item, dict):
            raw_item_dict = dict(item.raw_item)
        else:
            raw_item_dict = item.raw_item

        # Convert tool output-like items into protocol format for cross-SDK compatibility.
        if item.type in {"tool_call_output_item", "handoff_output_item"} and isinstance(
            raw_item_dict, dict
        ):
            raw_item_dict = self._convert_output_item_to_protocol(raw_item_dict)

        # Convert snake_case to camelCase for JSON serialization
        raw_item_dict = self._camelize_field_names(raw_item_dict)

        result: dict[str, Any] = {
            "type": item.type,
            "rawItem": raw_item_dict,
            "agent": {"name": item.agent.name},
        }

        # Add additional fields based on item type
        if hasattr(item, "output"):
            result["output"] = str(item.output)
        if hasattr(item, "source_agent"):
            result["sourceAgent"] = {"name": item.source_agent.name}
        if hasattr(item, "target_agent"):
            result["targetAgent"] = {"name": item.target_agent.name}
        if hasattr(item, "tool_name") and item.tool_name is not None:
            result["toolName"] = item.tool_name

        return result

    def _convert_output_item_to_protocol(self, raw_item_dict: dict[str, Any]) -> dict[str, Any]:
        """Convert API-format tool output items to protocol format."""
        converted = dict(raw_item_dict)
        call_id = cast(Optional[str], converted.get("call_id") or converted.get("callId"))

        converted["type"] = "function_call_result"

        if not converted.get("name"):
            converted["name"] = self._lookup_function_name(call_id or "")

        if not converted.get("status"):
            converted["status"] = "completed"

        return converted

    def _lookup_function_name(self, call_id: str) -> str:
        """Attempt to find the function name for the provided call_id."""
        if not call_id:
            return ""

        def _extract_name(raw: Any) -> str | None:
            candidate_call_id: str | None = None
            if isinstance(raw, dict):
                candidate_call_id = cast(Optional[str], raw.get("call_id") or raw.get("callId"))
                if candidate_call_id == call_id:
                    name_value = raw.get("name", "")
                    return str(name_value) if name_value else ""
            else:
                candidate_call_id = cast(
                    Optional[str],
                    getattr(raw, "call_id", None) or getattr(raw, "callId", None),
                )
                if candidate_call_id == call_id:
                    name_value = getattr(raw, "name", "")
                    return str(name_value) if name_value else ""
            return None

        # Search generated items first
        for run_item in self._generated_items:
            if run_item.type != "tool_call_item":
                continue
            name = _extract_name(run_item.raw_item)
            if name is not None:
                return name

        # Inspect last processed response
        if self._last_processed_response is not None:
            for run_item in self._last_processed_response.new_items:
                if run_item.type != "tool_call_item":
                    continue
                name = _extract_name(run_item.raw_item)
                if name is not None:
                    return name

        # Finally, inspect the original input list where the function call originated
        if isinstance(self._original_input, list):
            for input_item in self._original_input:
                if not isinstance(input_item, dict):
                    continue
                if input_item.get("type") != "function_call":
                    continue
                item_call_id = cast(
                    Optional[str], input_item.get("call_id") or input_item.get("callId")
                )
                if item_call_id == call_id:
                    name_value = input_item.get("name", "")
                    return str(name_value) if name_value else ""

        return ""

    def to_string(self) -> str:
        """Serializes the run state to a JSON string.

        Returns:
            JSON string representation of the run state.
        """
        return json.dumps(self.to_json(), indent=2)

    def set_tool_use_tracker_snapshot(self, snapshot: Mapping[str, Sequence[str]] | None) -> None:
        """Store a copy of the serialized tool-use tracker data."""
        if not snapshot:
            self._tool_use_tracker_snapshot = {}
            return

        normalized: dict[str, list[str]] = {}
        for agent_name, tools in snapshot.items():
            if not isinstance(agent_name, str):
                continue
            normalized[agent_name] = [tool for tool in tools if isinstance(tool, str)]
        self._tool_use_tracker_snapshot = normalized

    def get_tool_use_tracker_snapshot(self) -> dict[str, list[str]]:
        """Return a defensive copy of the tool-use tracker snapshot."""
        return {
            agent_name: list(tool_names)
            for agent_name, tool_names in self._tool_use_tracker_snapshot.items()
        }

    @staticmethod
    async def from_string(
        initial_agent: Agent[Any], state_string: str
    ) -> RunState[Any, Agent[Any]]:
        """Deserializes a run state from a JSON string.

        This method is used to deserialize a run state from a string that was serialized using
        the `to_string()` method.

        Args:
            initial_agent: The initial agent (used to build agent map for resolution).
            state_string: The JSON string to deserialize.

        Returns:
            A reconstructed RunState instance.

        Raises:
            UserError: If the string is invalid JSON or has incompatible schema version.
        """
        try:
            state_json = json.loads(state_string)
        except json.JSONDecodeError as e:
            raise UserError(f"Failed to parse run state JSON: {e}") from e

        # Check schema version
        schema_version = state_json.get("$schemaVersion")
        if not schema_version:
            raise UserError("Run state is missing schema version")
        if schema_version != CURRENT_SCHEMA_VERSION:
            raise UserError(
                f"Run state schema version {schema_version} is not supported. "
                f"Please use version {CURRENT_SCHEMA_VERSION}"
            )

        # Build agent map for name resolution
        agent_map = _build_agent_map(initial_agent)

        # Find the current agent
        current_agent_name = state_json["currentAgent"]["name"]
        current_agent = agent_map.get(current_agent_name)
        if not current_agent:
            raise UserError(f"Agent {current_agent_name} not found in agent map")

        # Rebuild context
        context_data = state_json["context"]
        usage = Usage()
        usage.requests = context_data["usage"]["requests"]
        usage.input_tokens = context_data["usage"]["inputTokens"]
        # Handle both array format (protocol) and object format (legacy Python)
        input_tokens_details_raw = context_data["usage"].get("inputTokensDetails") or {
            "cached_tokens": 0
        }
        if isinstance(input_tokens_details_raw, list) and len(input_tokens_details_raw) > 0:
            input_tokens_details_raw = input_tokens_details_raw[0]
        usage.input_tokens_details = TypeAdapter(InputTokensDetails).validate_python(
            input_tokens_details_raw
        )
        usage.output_tokens = context_data["usage"]["outputTokens"]
        # Handle both array format (protocol) and object format (legacy Python)
        output_tokens_details_raw = context_data["usage"].get("outputTokensDetails") or {
            "reasoning_tokens": 0
        }
        if isinstance(output_tokens_details_raw, list) and len(output_tokens_details_raw) > 0:
            output_tokens_details_raw = output_tokens_details_raw[0]
        usage.output_tokens_details = TypeAdapter(OutputTokensDetails).validate_python(
            output_tokens_details_raw
        )
        usage.total_tokens = context_data["usage"]["totalTokens"]
        usage.request_usage_entries = [
            RequestUsage(
                input_tokens=entry.get("inputTokens", 0),
                output_tokens=entry.get("outputTokens", 0),
                total_tokens=entry.get("totalTokens", 0),
                input_tokens_details=TypeAdapter(InputTokensDetails).validate_python(
                    entry.get("inputTokensDetails") or {"cached_tokens": 0}
                ),
                output_tokens_details=TypeAdapter(OutputTokensDetails).validate_python(
                    entry.get("outputTokensDetails") or {"reasoning_tokens": 0}
                ),
            )
            for entry in context_data["usage"].get("requestUsageEntries", [])
        ]
        # Note: requestUsageEntries.inputTokensDetails should remain as object (not array)

        context = RunContextWrapper(context=context_data.get("context", {}))
        context.usage = usage
        context._rebuild_approvals(context_data.get("approvals", {}))

        # Normalize originalInput to remove providerData fields that may have been
        # included during serialization. These fields are metadata and should
        # not be sent to the API.
        # Also convert protocol format (function_call_result) back to API format
        # (function_call_output) for internal use, since originalInput is used to
        # prepare input for the API.
        original_input_raw = state_json["originalInput"]
        if isinstance(original_input_raw, list):
            # Normalize each item in the list to remove providerData fields
            # and convert protocol format back to API format
            normalized_original_input = []
            for item in original_input_raw:
                if isinstance(item, dict):
                    item_dict = dict(item)
                    item_dict.pop("providerData", None)
                    item_dict.pop("provider_data", None)
                    normalized_item = _normalize_field_names(item_dict)
                    normalized_item = _convert_protocol_result_to_api(normalized_item)
                    normalized_original_input.append(normalized_item)
                else:
                    normalized_original_input.append(item)
        else:
            # If it's a string, use it as-is
            normalized_original_input = original_input_raw

        # Create the RunState instance
        state = RunState(
            context=context,
            original_input=normalized_original_input,
            starting_agent=current_agent,
            max_turns=state_json["maxTurns"],
        )

        state._current_turn = state_json["currentTurn"]

        # Reconstruct model responses
        state._model_responses = _deserialize_model_responses(state_json.get("modelResponses", []))

        # Reconstruct generated items
        state._generated_items = _deserialize_items(state_json.get("generatedItems", []), agent_map)

        # Reconstruct last processed response if present
        last_processed_response_data = state_json.get("lastProcessedResponse")
        if last_processed_response_data and state._context is not None:
            state._last_processed_response = await _deserialize_processed_response(
                last_processed_response_data, current_agent, state._context, agent_map
            )
        else:
            state._last_processed_response = None

        # Reconstruct guardrail results (simplified - full reconstruction would need more info)
        # For now, we store the basic info
        state._input_guardrail_results = []
        state._output_guardrail_results = []

        # Reconstruct current step if it's an interruption
        current_step_data = state_json.get("currentStep")
        if current_step_data and current_step_data.get("type") == "next_step_interruption":
            interruptions: list[RunItem] = []
            # Handle both old format (interruptions directly) and new format (wrapped in data)
            interruptions_data = current_step_data.get("data", {}).get(
                "interruptions", current_step_data.get("interruptions", [])
            )
            for item_data in interruptions_data:
                agent_name = item_data["agent"]["name"]
                agent = agent_map.get(agent_name)
                if agent:
                    # Normalize field names from JSON format (camelCase)
                    # to Python format (snake_case)
                    normalized_raw_item = _normalize_field_names(item_data["rawItem"])
                    raw_item = ResponseFunctionToolCall(**normalized_raw_item)
                    approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item)
                    interruptions.append(approval_item)

            # Import at runtime to avoid circular import
            from ._run_impl import NextStepInterruption

            state._current_step = NextStepInterruption(interruptions=interruptions)

        # Restore persisted item count for session tracking
        state._current_turn_persisted_item_count = state_json.get(
            "currentTurnPersistedItemCount", 0
        )
        state.set_tool_use_tracker_snapshot(state_json.get("toolUseTracker", {}))

        return state

    @staticmethod
    async def from_json(
        initial_agent: Agent[Any], state_json: dict[str, Any]
    ) -> RunState[Any, Agent[Any]]:
        """Deserializes a run state from a JSON dictionary.

        This method is used to deserialize a run state from a dict that was created using
        the `to_json()` method.

        Args:
            initial_agent: The initial agent (used to build agent map for resolution).
            state_json: The JSON dictionary to deserialize.

        Returns:
            A reconstructed RunState instance.

        Raises:
            UserError: If the dict has incompatible schema version.
        """
        # Check schema version
        schema_version = state_json.get("$schemaVersion")
        if not schema_version:
            raise UserError("Run state is missing schema version")
        if schema_version != CURRENT_SCHEMA_VERSION:
            raise UserError(
                f"Run state schema version {schema_version} is not supported. "
                f"Please use version {CURRENT_SCHEMA_VERSION}"
            )

        # Build agent map for name resolution
        agent_map = _build_agent_map(initial_agent)

        # Find the current agent
        current_agent_name = state_json["currentAgent"]["name"]
        current_agent = agent_map.get(current_agent_name)
        if not current_agent:
            raise UserError(f"Agent {current_agent_name} not found in agent map")

        # Rebuild context
        context_data = state_json["context"]
        usage = Usage()
        usage.requests = context_data["usage"]["requests"]
        usage.input_tokens = context_data["usage"]["inputTokens"]
        # Handle both array format (protocol) and object format (legacy Python)
        input_tokens_details_raw = context_data["usage"].get("inputTokensDetails") or {
            "cached_tokens": 0
        }
        if isinstance(input_tokens_details_raw, list) and len(input_tokens_details_raw) > 0:
            input_tokens_details_raw = input_tokens_details_raw[0]
        usage.input_tokens_details = TypeAdapter(InputTokensDetails).validate_python(
            input_tokens_details_raw
        )
        usage.output_tokens = context_data["usage"]["outputTokens"]
        # Handle both array format (protocol) and object format (legacy Python)
        output_tokens_details_raw = context_data["usage"].get("outputTokensDetails") or {
            "reasoning_tokens": 0
        }
        if isinstance(output_tokens_details_raw, list) and len(output_tokens_details_raw) > 0:
            output_tokens_details_raw = output_tokens_details_raw[0]
        usage.output_tokens_details = TypeAdapter(OutputTokensDetails).validate_python(
            output_tokens_details_raw
        )
        usage.total_tokens = context_data["usage"]["totalTokens"]
        usage.request_usage_entries = [
            RequestUsage(
                input_tokens=entry.get("inputTokens", 0),
                output_tokens=entry.get("outputTokens", 0),
                total_tokens=entry.get("totalTokens", 0),
                input_tokens_details=TypeAdapter(InputTokensDetails).validate_python(
                    entry.get("inputTokensDetails") or {"cached_tokens": 0}
                ),
                output_tokens_details=TypeAdapter(OutputTokensDetails).validate_python(
                    entry.get("outputTokensDetails") or {"reasoning_tokens": 0}
                ),
            )
            for entry in context_data["usage"].get("requestUsageEntries", [])
        ]
        # Note: requestUsageEntries.inputTokensDetails should remain as object (not array)

        context = RunContextWrapper(context=context_data.get("context", {}))
        context.usage = usage
        context._rebuild_approvals(context_data.get("approvals", {}))

        # Normalize originalInput to remove providerData fields that may have been
        # included during serialization. These fields are metadata and should
        # not be sent to the API.
        # Also convert protocol format (function_call_result) back to API format
        # (function_call_output) for internal use, since originalInput is used to
        # prepare input for the API.
        original_input_raw = state_json["originalInput"]
        if isinstance(original_input_raw, list):
            # Normalize each item in the list to remove providerData fields
            # and convert protocol format back to API format
            normalized_original_input = []
            for item in original_input_raw:
                if isinstance(item, dict):
                    item_dict = dict(item)
                    item_dict.pop("providerData", None)
                    item_dict.pop("provider_data", None)
                    normalized_item = _normalize_field_names(item_dict)
                    # Convert protocol format (function_call_result) back to API format
                    # (function_call_output) for internal use
                    item_type = normalized_item.get("type")
                    if item_type == "function_call_result":
                        normalized_item = dict(normalized_item)
                        normalized_item["type"] = "function_call_output"
                        # Remove protocol-only fields
                        normalized_item.pop("name", None)
                        normalized_item.pop("status", None)
                    normalized_original_input.append(normalized_item)
                else:
                    normalized_original_input.append(item)
        else:
            # If it's a string, use it as-is
            normalized_original_input = original_input_raw

        # Create the RunState instance
        state = RunState(
            context=context,
            original_input=normalized_original_input,
            starting_agent=current_agent,
            max_turns=state_json["maxTurns"],
        )

        state._current_turn = state_json["currentTurn"]

        # Reconstruct model responses
        state._model_responses = _deserialize_model_responses(state_json.get("modelResponses", []))

        # Reconstruct generated items
        state._generated_items = _deserialize_items(state_json.get("generatedItems", []), agent_map)

        # Reconstruct last processed response if present
        last_processed_response_data = state_json.get("lastProcessedResponse")
        if last_processed_response_data and state._context is not None:
            state._last_processed_response = await _deserialize_processed_response(
                last_processed_response_data, current_agent, state._context, agent_map
            )
        else:
            state._last_processed_response = None

        # Reconstruct guardrail results (simplified - full reconstruction would need more info)
        # For now, we store the basic info
        state._input_guardrail_results = []
        state._output_guardrail_results = []

        # Reconstruct current step if it's an interruption
        current_step_data = state_json.get("currentStep")
        if current_step_data and current_step_data.get("type") == "next_step_interruption":
            interruptions: list[RunItem] = []
            # Handle both old format (interruptions directly) and new format (wrapped in data)
            interruptions_data = current_step_data.get("data", {}).get(
                "interruptions", current_step_data.get("interruptions", [])
            )
            for item_data in interruptions_data:
                agent_name = item_data["agent"]["name"]
                agent = agent_map.get(agent_name)
                if agent:
                    # Normalize field names from JSON format (camelCase)
                    # to Python format (snake_case)
                    normalized_raw_item = _normalize_field_names(item_data["rawItem"])
                    raw_item = ResponseFunctionToolCall(**normalized_raw_item)
                    approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item)
                    interruptions.append(approval_item)

            # Import at runtime to avoid circular import
            from ._run_impl import NextStepInterruption

            state._current_step = NextStepInterruption(interruptions=interruptions)

        # Restore persisted item count for session tracking
        state._current_turn_persisted_item_count = state_json.get(
            "currentTurnPersistedItemCount", 0
        )
        state.set_tool_use_tracker_snapshot(state_json.get("toolUseTracker", {}))

        return state


async def _deserialize_processed_response(
    processed_response_data: dict[str, Any],
    current_agent: Agent[Any],
    context: RunContextWrapper[Any],
    agent_map: dict[str, Agent[Any]],
) -> ProcessedResponse:
    """Deserialize a ProcessedResponse from JSON data.

    Args:
        processed_response_data: Serialized ProcessedResponse dictionary.
        current_agent: The current agent (used to get tools and handoffs).
        context: The run context wrapper.
        agent_map: Map of agent names to agents.

    Returns:
        A reconstructed ProcessedResponse instance.
    """
    # Deserialize new items
    new_items = _deserialize_items(processed_response_data.get("newItems", []), agent_map)

    # Get all tools from the agent
    if hasattr(current_agent, "get_all_tools"):
        all_tools = await current_agent.get_all_tools(context)
    else:
        all_tools = []

    # Build tool maps
    tools_map = {tool.name: tool for tool in all_tools if isinstance(tool, FunctionTool)}
    computer_tools_map = {
        tool.name: tool for tool in all_tools if hasattr(tool, "type") and tool.type == "computer"
    }
    shell_tools_map = {
        tool.name: tool for tool in all_tools if hasattr(tool, "type") and tool.type == "shell"
    }
    apply_patch_tools_map = {
        tool.name: tool
        for tool in all_tools
        if hasattr(tool, "type") and tool.type == "apply_patch"
    }
    # Build MCP tools map
    mcp_tools_map = {tool.name: tool for tool in all_tools if isinstance(tool, HostedMCPTool)}

    # Get handoffs from the agent
    handoffs_map: dict[str, Handoff[Any, Agent[Any]]] = {}
    if hasattr(current_agent, "handoffs"):
        for handoff in current_agent.handoffs:
            # Only include Handoff instances, not Agent instances
            if isinstance(handoff, Handoff):
                if hasattr(handoff, "tool_name"):
                    handoffs_map[handoff.tool_name] = handoff
                elif hasattr(handoff, "name"):
                    handoffs_map[handoff.name] = handoff

    # Import at runtime to avoid circular import
    from ._run_impl import (
        ProcessedResponse,
        ToolRunApplyPatchCall,
        ToolRunComputerAction,
        ToolRunFunction,
        ToolRunHandoff,
        ToolRunMCPApprovalRequest,
        ToolRunShellCall,
    )

    # Deserialize handoffs
    handoffs = []
    for handoff_data in processed_response_data.get("handoffs", []):
        tool_call_data = _normalize_field_names(handoff_data.get("toolCall", {}))
        handoff_name = handoff_data.get("handoff", {}).get("toolName") or handoff_data.get(
            "handoff", {}
        ).get("tool_name")
        if handoff_name and handoff_name in handoffs_map:
            tool_call = ResponseFunctionToolCall(**tool_call_data)
            handoff = handoffs_map[handoff_name]
            handoffs.append(ToolRunHandoff(tool_call=tool_call, handoff=handoff))

    # Deserialize functions
    functions = []
    for func_data in processed_response_data.get("functions", []):
        tool_call_data = _normalize_field_names(func_data.get("toolCall", {}))
        tool_name = func_data.get("tool", {}).get("name")
        if tool_name and tool_name in tools_map:
            tool_call = ResponseFunctionToolCall(**tool_call_data)
            function_tool = tools_map[tool_name]
            functions.append(ToolRunFunction(tool_call=tool_call, function_tool=function_tool))

    # Deserialize computer actions
    computer_actions = []
    for action_data in processed_response_data.get("computerActions", []):
        tool_call_data = _normalize_field_names(action_data.get("toolCall", {}))
        computer_name = action_data.get("computer", {}).get("name")
        if computer_name and computer_name in computer_tools_map:
            computer_tool_call = ResponseComputerToolCall(**tool_call_data)
            computer_tool = computer_tools_map[computer_name]
            # Only include ComputerTool instances
            if isinstance(computer_tool, ComputerTool):
                computer_actions.append(
                    ToolRunComputerAction(tool_call=computer_tool_call, computer_tool=computer_tool)
                )

    # Deserialize shell actions
    shell_actions = []
    for action_data in processed_response_data.get("shellActions", []):
        tool_call_data = _normalize_field_names(action_data.get("toolCall", {}))
        shell_name = action_data.get("shell", {}).get("name")
        if shell_name and shell_name in shell_tools_map:
            try:
                shell_call = TypeAdapter(LocalShellCall).validate_python(tool_call_data)
            except ValidationError:
                shell_call = tool_call_data  # type: ignore[assignment]
            shell_tool = shell_tools_map[shell_name]
            # Type assertion: shell_tools_map only contains ShellTool instances
            if isinstance(shell_tool, ShellTool):
                shell_actions.append(ToolRunShellCall(tool_call=shell_call, shell_tool=shell_tool))

    # Deserialize apply patch actions
    apply_patch_actions = []
    for action_data in processed_response_data.get("applyPatchActions", []):
        tool_call_data = _normalize_field_names(action_data.get("toolCall", {}))
        apply_patch_name = action_data.get("applyPatch", {}).get("name")
        if apply_patch_name and apply_patch_name in apply_patch_tools_map:
            try:
                apply_patch_tool_call = ResponseFunctionToolCall(**tool_call_data)
            except Exception:
                apply_patch_tool_call = tool_call_data  # type: ignore[assignment]
            apply_patch_tool = apply_patch_tools_map[apply_patch_name]
            # Type assertion: apply_patch_tools_map only contains ApplyPatchTool instances
            if isinstance(apply_patch_tool, ApplyPatchTool):
                apply_patch_actions.append(
                    ToolRunApplyPatchCall(
                        tool_call=apply_patch_tool_call, apply_patch_tool=apply_patch_tool
                    )
                )

    # Deserialize MCP approval requests
    mcp_approval_requests = []
    for request_data in processed_response_data.get("mcpApprovalRequests", []):
        request_item_data = request_data.get("requestItem", {})
        raw_item_data = _normalize_field_names(request_item_data.get("rawItem", {}))
        # Create a McpApprovalRequest from the raw item data
        request_item_adapter: TypeAdapter[McpApprovalRequest] = TypeAdapter(McpApprovalRequest)
        request_item = request_item_adapter.validate_python(raw_item_data)

        # Deserialize mcp_tool - this is a HostedMCPTool, which we need to
        # find from the agent's tools
        mcp_tool_data = request_data.get("mcpTool", {})
        if not mcp_tool_data:
            # Skip if mcp_tool is not available
            continue

        # Try to find the MCP tool from the agent's tools by name
        mcp_tool_name = mcp_tool_data.get("name")
        mcp_tool = mcp_tools_map.get(mcp_tool_name) if mcp_tool_name else None

        if mcp_tool:
            mcp_approval_requests.append(
                ToolRunMCPApprovalRequest(
                    request_item=request_item,
                    mcp_tool=mcp_tool,
                )
            )

    return ProcessedResponse(
        new_items=new_items,
        handoffs=handoffs,
        functions=functions,
        computer_actions=computer_actions,
        local_shell_calls=[],  # Not serialized in JSON schema
        shell_calls=shell_actions,
        apply_patch_calls=apply_patch_actions,
        tools_used=processed_response_data.get("toolsUsed", []),
        mcp_approval_requests=mcp_approval_requests,
        interruptions=[],  # Not serialized in ProcessedResponse
    )


def _normalize_field_names(data: dict[str, Any]) -> dict[str, Any]:
    """Normalize field names from camelCase (JSON) to snake_case (Python).

    This function converts common field names from JSON's camelCase convention
    to Python's snake_case convention.

    Args:
        data: Dictionary with potentially camelCase field names.

    Returns:
        Dictionary with normalized snake_case field names.
    """
    if not isinstance(data, dict):
        return data

    normalized: dict[str, Any] = {}
    field_mapping = {
        "callId": "call_id",
        "responseId": "response_id",
    }

    for key, value in data.items():
        # Drop providerData/provider_data entirely (matches JS behavior)
        if key in {"providerData", "provider_data"}:
            continue

        normalized_key = field_mapping.get(key, key)

        # Recursively normalize nested dictionaries
        if isinstance(value, dict):
            normalized[normalized_key] = _normalize_field_names(value)
        elif isinstance(value, list):
            normalized[normalized_key] = [
                _normalize_field_names(item) if isinstance(item, dict) else item for item in value
            ]
        else:
            normalized[normalized_key] = value

    return normalized


def _build_agent_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]:
    """Build a map of agent names to agents by traversing handoffs.

    Args:
        initial_agent: The starting agent.

    Returns:
        Dictionary mapping agent names to agent instances.
    """
    agent_map: dict[str, Agent[Any]] = {}
    queue = [initial_agent]

    while queue:
        current = queue.pop(0)
        if current.name in agent_map:
            continue
        agent_map[current.name] = current

        # Add handoff agents to the queue
        for handoff in current.handoffs:
            # Handoff can be either an Agent or a Handoff object with an .agent attribute
            handoff_agent = handoff if not hasattr(handoff, "agent") else handoff.agent
            if handoff_agent and handoff_agent.name not in agent_map:  # type: ignore[union-attr]
                queue.append(handoff_agent)  # type: ignore[arg-type]

    return agent_map


def _deserialize_model_responses(responses_data: list[dict[str, Any]]) -> list[ModelResponse]:
    """Deserialize model responses from JSON data.

    Args:
        responses_data: List of serialized model response dictionaries.

    Returns:
        List of ModelResponse instances.
    """

    result = []
    for resp_data in responses_data:
        usage = Usage()
        usage.requests = resp_data["usage"]["requests"]
        usage.input_tokens = resp_data["usage"]["inputTokens"]
        # Handle both array format (protocol) and object format (legacy Python)
        input_tokens_details_raw = resp_data["usage"].get("inputTokensDetails") or {
            "cached_tokens": 0
        }
        if isinstance(input_tokens_details_raw, list) and len(input_tokens_details_raw) > 0:
            input_tokens_details_raw = input_tokens_details_raw[0]
        usage.input_tokens_details = TypeAdapter(InputTokensDetails).validate_python(
            input_tokens_details_raw
        )
        usage.output_tokens = resp_data["usage"]["outputTokens"]
        # Handle both array format (protocol) and object format (legacy Python)
        output_tokens_details_raw = resp_data["usage"].get("outputTokensDetails") or {
            "reasoning_tokens": 0
        }
        if isinstance(output_tokens_details_raw, list) and len(output_tokens_details_raw) > 0:
            output_tokens_details_raw = output_tokens_details_raw[0]
        usage.output_tokens_details = TypeAdapter(OutputTokensDetails).validate_python(
            output_tokens_details_raw
        )
        usage.total_tokens = resp_data["usage"]["totalTokens"]
        usage.request_usage_entries = [
            RequestUsage(
                input_tokens=entry.get("inputTokens", 0),
                output_tokens=entry.get("outputTokens", 0),
                total_tokens=entry.get("totalTokens", 0),
                input_tokens_details=TypeAdapter(InputTokensDetails).validate_python(
                    entry.get("inputTokensDetails") or {"cached_tokens": 0}
                ),
                output_tokens_details=TypeAdapter(OutputTokensDetails).validate_python(
                    entry.get("outputTokensDetails") or {"reasoning_tokens": 0}
                ),
            )
            for entry in resp_data["usage"].get("requestUsageEntries", [])
        ]

        # Normalize output items from JSON format (camelCase) to Python format (snake_case)
        normalized_output = [
            _normalize_field_names(item) if isinstance(item, dict) else item
            for item in resp_data["output"]
        ]

        output_adapter: TypeAdapter[Any] = TypeAdapter(list[Any])
        output = output_adapter.validate_python(normalized_output)

        # Handle both responseId (JSON) and response_id (Python) formats
        response_id = resp_data.get("responseId") or resp_data.get("response_id")

        result.append(
            ModelResponse(
                usage=usage,
                output=output,
                response_id=response_id,
            )
        )

    return result


def _deserialize_items(
    items_data: list[dict[str, Any]], agent_map: dict[str, Agent[Any]]
) -> list[RunItem]:
    """Deserialize run items from JSON data.

    Args:
        items_data: List of serialized run item dictionaries.
        agent_map: Map of agent names to agent instances.

    Returns:
        List of RunItem instances.
    """

    result: list[RunItem] = []

    for item_data in items_data:
        item_type = item_data.get("type")
        if not item_type:
            logger.warning("Item missing type field, skipping")
            continue

        # Handle items that might not have an agent field (e.g., from cross-SDK serialization)
        agent_name: str | None = None
        agent_data = item_data.get("agent")
        if agent_data:
            if isinstance(agent_data, dict):
                agent_name = agent_data.get("name")
            elif isinstance(agent_data, str):
                agent_name = agent_data
        elif "agentName" in item_data:
            # Handle alternative field name
            agent_name = item_data.get("agentName")

        if not agent_name and item_type == "handoff_output_item":
            # Older serializations may store only source/target agent fields.
            source_agent_data = item_data.get("sourceAgent")
            if isinstance(source_agent_data, dict):
                agent_name = source_agent_data.get("name")
            elif isinstance(source_agent_data, str):
                agent_name = source_agent_data
            if not agent_name:
                target_agent_data = item_data.get("targetAgent")
                if isinstance(target_agent_data, dict):
                    agent_name = target_agent_data.get("name")
                elif isinstance(target_agent_data, str):
                    agent_name = target_agent_data

        if not agent_name:
            logger.warning(f"Item missing agent field, skipping: {item_type}")
            continue

        agent = agent_map.get(agent_name)
        if not agent:
            logger.warning(f"Agent {agent_name} not found, skipping item")
            continue

        raw_item_data = item_data["rawItem"]

        # Normalize field names from JSON format (camelCase) to Python format (snake_case)
        normalized_raw_item = _normalize_field_names(raw_item_data)

        try:
            if item_type == "message_output_item":
                raw_item_msg = ResponseOutputMessage(**normalized_raw_item)
                result.append(MessageOutputItem(agent=agent, raw_item=raw_item_msg))

            elif item_type == "tool_call_item":
                raw_item_tool = ResponseFunctionToolCall(**normalized_raw_item)
                result.append(ToolCallItem(agent=agent, raw_item=raw_item_tool))

            elif item_type == "tool_call_output_item":
                # For tool call outputs, validate and convert the raw dict
                # Try to determine the type based on the dict structure
                normalized_raw_item = _convert_protocol_result_to_api(normalized_raw_item)
                output_type = normalized_raw_item.get("type")

                raw_item_output: FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput
                if output_type == "function_call_output":
                    function_adapter: TypeAdapter[FunctionCallOutput] = TypeAdapter(
                        FunctionCallOutput
                    )
                    raw_item_output = function_adapter.validate_python(normalized_raw_item)
                elif output_type == "computer_call_output":
                    computer_adapter: TypeAdapter[ComputerCallOutput] = TypeAdapter(
                        ComputerCallOutput
                    )
                    raw_item_output = computer_adapter.validate_python(normalized_raw_item)
                elif output_type == "local_shell_call_output":
                    shell_adapter: TypeAdapter[LocalShellCallOutput] = TypeAdapter(
                        LocalShellCallOutput
                    )
                    raw_item_output = shell_adapter.validate_python(normalized_raw_item)
                else:
                    # Fallback: try to validate as union type
                    union_adapter: TypeAdapter[
                        FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput
                    ] = TypeAdapter(FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput)
                    raw_item_output = union_adapter.validate_python(normalized_raw_item)
                result.append(
                    ToolCallOutputItem(
                        agent=agent,
                        raw_item=raw_item_output,
                        output=item_data.get("output", ""),
                    )
                )

            elif item_type == "reasoning_item":
                raw_item_reason = ResponseReasoningItem(**normalized_raw_item)
                result.append(ReasoningItem(agent=agent, raw_item=raw_item_reason))

            elif item_type == "handoff_call_item":
                raw_item_handoff = ResponseFunctionToolCall(**normalized_raw_item)
                result.append(HandoffCallItem(agent=agent, raw_item=raw_item_handoff))

            elif item_type == "handoff_output_item":
                source_agent = agent_map.get(item_data["sourceAgent"]["name"])
                target_agent = agent_map.get(item_data["targetAgent"]["name"])
                if source_agent and target_agent:
                    # For handoff output items, we need to validate the raw_item
                    # as a TResponseInputItem (which is a union type)
                    # If validation fails, use the raw dict as-is (for test compatibility)
                    try:
                        input_item_adapter: TypeAdapter[TResponseInputItem] = TypeAdapter(
                            TResponseInputItem
                        )
                        raw_item_handoff_output = input_item_adapter.validate_python(
                            _convert_protocol_result_to_api(normalized_raw_item)
                        )
                    except ValidationError:
                        # If validation fails, use the raw dict as-is
                        # This allows tests to use mock data that doesn't match
                        # the exact TResponseInputItem union types
                        raw_item_handoff_output = normalized_raw_item  # type: ignore[assignment]
                    result.append(
                        HandoffOutputItem(
                            agent=agent,
                            raw_item=raw_item_handoff_output,
                            source_agent=source_agent,
                            target_agent=target_agent,
                        )
                    )

            elif item_type == "mcp_list_tools_item":
                raw_item_mcp_list = McpListTools(**normalized_raw_item)
                result.append(MCPListToolsItem(agent=agent, raw_item=raw_item_mcp_list))

            elif item_type == "mcp_approval_request_item":
                raw_item_mcp_req = McpApprovalRequest(**normalized_raw_item)
                result.append(MCPApprovalRequestItem(agent=agent, raw_item=raw_item_mcp_req))

            elif item_type == "mcp_approval_response_item":
                # Validate and convert the raw dict to McpApprovalResponse
                approval_response_adapter: TypeAdapter[McpApprovalResponse] = TypeAdapter(
                    McpApprovalResponse
                )
                raw_item_mcp_response = approval_response_adapter.validate_python(
                    normalized_raw_item
                )
                result.append(MCPApprovalResponseItem(agent=agent, raw_item=raw_item_mcp_response))

            elif item_type == "tool_approval_item":
                # Extract toolName if present (for backwards compatibility)
                tool_name = item_data.get("toolName")
                # Try to deserialize as ResponseFunctionToolCall first (most common case)
                # If that fails, use the dict as-is for flexibility
                try:
                    raw_item_approval = ResponseFunctionToolCall(**normalized_raw_item)
                except Exception:
                    # If deserialization fails, use dict for flexibility with other tool types
                    raw_item_approval = normalized_raw_item  # type: ignore[assignment]
                result.append(
                    ToolApprovalItem(agent=agent, raw_item=raw_item_approval, tool_name=tool_name)
                )

        except Exception as e:
            logger.warning(f"Failed to deserialize item of type {item_type}: {e}")
            continue

    return result


def _convert_protocol_result_to_api(raw_item: dict[str, Any]) -> dict[str, Any]:
    """Convert protocol format (function_call_result) to API format (function_call_output)."""
    if raw_item.get("type") != "function_call_result":
        return raw_item

    api_item = dict(raw_item)
    api_item["type"] = "function_call_output"
    api_item.pop("name", None)
    api_item.pop("status", None)
    return normalize_function_call_output_payload(api_item)


def _clone_original_input(original_input: str | list[Any]) -> str | list[Any]:
    """Return a deep copy of the original input so later mutations don't leak into saved state."""
    if isinstance(original_input, str):
        return original_input
    return copy.deepcopy(original_input)
