import json
import asyncio
import time
from pathlib import Path
import traceback
import datetime as _dt
from typing import Optional, Awaitable, Callable, List, Any, Dict, Iterable, AsyncGenerator, TypedDict, Literal, cast, TypeAlias, Type
from pydantic import BaseModel

from .context import Context
from .model import (
    Message,
    UserContent,
    ToolMessageContent,
    MessageTextRenderable,
    CompactRecord,
    Role,
    ROLE_USER,
    ROLE_ASSISTANT,
    ROLE_TOOL,
)
from .agent_stream_state import (
    TurnStreamState,
    StreamTransitionUnion,
    ToolCallDeltaPayload,
    ToolRecord,
    UsageData,
    ingest_stream_event,
    reduce_stream_signal,
    build_assembled_debug,
    build_tool_records,
)
from .tools.base import ToolResult, BaseToolRegistry, BaseTool
from .runtime_config import OturnConfig
from .nucleus import AsyncPubSub
from .llm_transport import (
    LiteLLMTransport,
    start_global_litellm_prewarm as _start_global_litellm_prewarm,
    get_global_litellm_prewarm_error as _get_global_litellm_prewarm_error,
)
from .tool_runtime import ToolRuntime
from .mcp_adapter import MCPAdapter
from .events import (
    EventPayload,
    ToolCallDebugEvent,
    StreamOutEvent,
    ToolCallDeltaEvent,
    ev_assistant_delta,
    ev_assistant_reasoning_delta,
    ev_error,
    ev_assistant_final,
    ev_message_sent,
    ev_run_aborted,
    ev_debug_latency,
    ev_tool_call_delta,
    ev_tool_call_debug_delta,
    ev_tool_call_debug_warning,
    ev_tool_call_debug_assembled,
)


class _InputQueueItem(TypedDict):
    status: str
    text: str
    type: str


JsonValue: TypeAlias = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
JsonObject: TypeAlias = dict[str, JsonValue]


class _ApiToolFunction(TypedDict):
    name: str
    arguments: str


class _ApiToolCall(TypedDict):
    id: str
    type: str
    function: _ApiToolFunction


class _ApiMessage(TypedDict, total=False):
    role: Role
    content: str
    name: str
    tool_call_id: str
    reasoning_content: str
    tool_calls: list[_ApiToolCall]


class _ToolFunctionSchema(TypedDict):
    name: str
    description: str
    parameters: JsonObject


class _ToolSchema(TypedDict):
    type: Literal["function"]
    function: _ToolFunctionSchema


class _PendingEvent(TypedDict, total=False):
    future_id: str


ApiMessagesInput = Context | list[Message] | list[_ApiMessage]


def start_global_litellm_prewarm(*, blocking: bool = False, timeout: float | None = None) -> bool:
    return _start_global_litellm_prewarm(blocking=blocking, timeout=timeout)


def get_global_litellm_prewarm_error() -> str | None:
    return _get_global_litellm_prewarm_error()


def _normalize_content_for_api(content: str | UserContent | ToolMessageContent | Any) -> str:
    if isinstance(content, MessageTextRenderable):
        try:
            return str(content.to_message_text())
        except Exception:
            pass
    if isinstance(content, BaseModel):
        return json.dumps(content.model_dump(), ensure_ascii=False)
    if isinstance(content, str):
        return content
    return str(content)


def _is_compact_like(item: Any) -> bool:
    if isinstance(item, dict):
        return item.get("type") == "compact"
    return getattr(item, "type", None) == "compact" and hasattr(item, "value")


def _compact_value(item: Any) -> str:
    if isinstance(item, dict):
        return str(item.get("value", ""))
    return str(getattr(item, "value", ""))


def _is_message_like(item: Any) -> bool:
    return hasattr(item, "role") and hasattr(item, "content")


def _format_messages(messages: Iterable[Any]) -> list[_ApiMessage]:
    out: list[_ApiMessage] = []
    for m in messages:
        if not m.role:
            continue

        if m.role == ROLE_TOOL and isinstance(m.content, dict):
            msg_content = json.dumps(m.content, ensure_ascii=False)
        else:
            msg_content = _normalize_content_for_api(m.content)

        msg_dict: _ApiMessage = {"role": m.role, "content": msg_content}
        if m.name:
            msg_dict["name"] = m.name
        if m.tool_call_id:
            msg_dict["tool_call_id"] = m.tool_call_id
        if m.reasoning_content:
            msg_dict["reasoning_content"] = m.reasoning_content
        if m.extra:
            msg_dict.update(m.extra)

        if m.tool_calls:
            try:
                tool_calls: list[_ApiToolCall] = []
                for tc in m.tool_calls:
                    tool_calls.append(
                        {
                            "id": tc.id,
                            "type": tc.type,
                            "function": {
                                "name": tc.function.name,
                                "arguments": json.dumps(tc.function.arguments if tc.function.arguments is not None else {}),
                            },
                        }
                    )
                msg_dict["tool_calls"] = tool_calls
            except Exception:
                pass

        out.append(msg_dict)
    return out


def _context_to_api_format(ctx: Any) -> list[_ApiMessage]:
    out: list[_ApiMessage] = []
    i = 0
    history = getattr(ctx, "history", [])

    while i < len(history):
        item = history[i]

        if _is_compact_like(item):
            out.clear()
            out.append(
                {
                    "role": ROLE_ASSISTANT,
                    "content": _compact_value(item),
                }
            )
            i += 1
            continue

        if not _is_message_like(item):
            i += 1
            continue

        if item.role == ROLE_ASSISTANT:
            msg_dict: _ApiMessage = {"role": ROLE_ASSISTANT, "content": _normalize_content_for_api(item.content)}
            if item.name:
                msg_dict["name"] = item.name
            if item.reasoning_content:
                msg_dict["reasoning_content"] = item.reasoning_content
            if item.extra:
                msg_dict.update(item.extra)

            tool_ids = []
            if item.tool_calls:
                try:
                    tool_calls: list[_ApiToolCall] = []
                    for tc in item.tool_calls:
                        tool_call_dict: _ApiToolCall = {
                            "id": tc.id,
                            "type": tc.type,
                            "function": {
                                "name": tc.function.name,
                                "arguments": json.dumps(tc.function.arguments if tc.function.arguments is not None else {}),
                            },
                        }
                        tool_calls.append(tool_call_dict)
                        tool_ids.append(tc.id)
                    msg_dict["tool_calls"] = tool_calls
                except Exception:
                    pass

            out.append(msg_dict)
            j = i + 1
            processed_tool_ids = set()

            while j < len(history):
                tool_item = history[j]
                if not _is_message_like(tool_item) or tool_item.role != ROLE_TOOL:
                    break

                if tool_item.tool_call_id in tool_ids:
                    processed_tool_ids.add(tool_item.tool_call_id)
                    if isinstance(tool_item.content, dict):
                        msg_content = json.dumps(tool_item.content, ensure_ascii=False)
                    else:
                        msg_content = _normalize_content_for_api(tool_item.content)

                    tool_msg_dict: _ApiMessage = {
                        "role": ROLE_TOOL,
                        "tool_call_id": tool_item.tool_call_id,
                        "content": msg_content,
                    }
                    if tool_item.name:
                        tool_msg_dict["name"] = tool_item.name
                    out.append(tool_msg_dict)
                j += 1

            missing_tool_ids = set(tool_ids) - processed_tool_ids
            for missing_id in missing_tool_ids:
                missing_payload = ToolMessageContent(
                    status="failure",
                    error="tool_result_missing",
                    data={
                        "message": "The tool execution result could not be obtained due to environmental or user-related issues. Please retry or check the relevant configuration."
                    },
                )
                out.append(
                    {
                        "role": ROLE_TOOL,
                        "tool_call_id": missing_id,
                        "name": "missing",
                        "content": json.dumps(missing_payload.model_dump(), ensure_ascii=False),
                    }
                )
            i = j
        elif item.role == ROLE_TOOL:
            i += 1
        else:
            msg_dict: _ApiMessage = {"role": item.role, "content": _normalize_content_for_api(item.content)}
            if item.name:
                msg_dict["name"] = item.name
            if item.tool_call_id:
                msg_dict["tool_call_id"] = item.tool_call_id
            if item.reasoning_content:
                msg_dict["reasoning_content"] = item.reasoning_content
            if item.extra:
                msg_dict.update(item.extra)
            out.append(msg_dict)
            i += 1

    return out


def _to_api_messages(messages: ApiMessagesInput | Any) -> list[_ApiMessage]:
    if hasattr(messages, "history"):
        return _context_to_api_format(messages)
    if isinstance(messages, list):
        if not messages:
            return []
        if _is_message_like(messages[0]):
            return _format_messages(cast(list[Message], messages))
        if isinstance(messages[0], dict):
            return cast(list[_ApiMessage], messages)
    raise TypeError("messages must be Context or list[Message|ApiMessage]")
class Oturn:
    def __init__(
        self,
        work_dir: Path,
        config: OturnConfig,
        tool_policy: Optional[Callable[[str, JsonObject], Awaitable[bool]]] = None,
        tools: Optional[List[Type[BaseTool]]] = None,
    ) -> None:
        self.work_dir = work_dir.absolute()
        self.cfg = config
        self.model = str(self.cfg.model or "").strip()
        self.api_base = str(self.cfg.provider.api_base or "").strip()
        self.api_key = self.cfg.provider.api_key
        self.max_tokens = self.cfg.model_config.max_output_tokens
        self.temperature = self.cfg.model_config.temperature
        self.extra_body = self.cfg.model_config.extra_body
        self.timeout = 60.0
        self._transport = LiteLLMTransport(
            model=self.model,
            timeout=self.timeout,
            max_tokens=self.max_tokens,
            temperature=self.temperature,
            extra_body=self.extra_body,
            api_base=self.api_base,
            api_key=self.api_key,
        )
        if not self.model:
            raise ValueError("Model name is required")
        self._cancel_evt: asyncio.Event = asyncio.Event()
        self._debug_loop = bool(self.cfg.debug_loop)
        self._tool_registry = BaseToolRegistry()
        for tool_cls in tools or []:
            self._tool_registry.auto_register_tool(tool_cls)
        # tool_policy: async (name, args_dict) -> bool
        async def _allow(_: str, __: JsonObject) -> bool:
            return True
        self._tool_policy: Callable[[str, JsonObject], Awaitable[bool]] = tool_policy or _allow
        self._pending_events: list[_PendingEvent] = []
        self._pending_lock = asyncio.Lock()
        self._input_queue: asyncio.Queue[_InputQueueItem] = asyncio.Queue()
        self._input_shadow: list[_InputQueueItem] = []
        self._pubsub: AsyncPubSub[EventPayload] = AsyncPubSub()
        self._tool_call_deltas: dict[str, ToolCallDeltaEvent] = {}
        self._mcp_adapter = MCPAdapter()
        self._tool_runtime = ToolRuntime(
            tool_registry=self._tool_registry,
            publish=self._publish_to_subscribers,
            execute_remote_tool=self._execute_remote_tool,
            cancel_event=self._cancel_evt,
            debug_tools=bool(self.cfg.provider.debug_tools),
        )

    async def add_pending_event(self, payload: _PendingEvent) -> None:
        if "future_id" not in payload:
            return
        async with self._pending_lock:
            self._pending_events.append(payload)
            if len(self._pending_events) > 200:
                self._pending_events = self._pending_events[-200:]

    async def resolve_pending_future_id(self, future_id: str) -> None:
        async with self._pending_lock:
            self._pending_events = [p for p in self._pending_events if p.get("future_id") != future_id]

    async def get_pending_events(self) -> list[_PendingEvent]:
        async with self._pending_lock:
            return list(self._pending_events)

    def enqueue_user_input_nowait(self, text: str, status: str = "agent", input_type: str = "default") -> bool:
        try:
            payload: _InputQueueItem = {
                "status": str(status or "agent"),
                "text": str(text or ""),
                "type": str(input_type or "default"),
            }
            self._input_queue.put_nowait(payload)
            self._input_shadow.append(payload)
            return True
        except Exception:
            return False

    def pending_input_count(self) -> int:
        try:
            return int(len(self._input_shadow))
        except Exception:
            return 0

    def get_input_queue_snapshot(self, limit: int = 100) -> list[_InputQueueItem]:
        if limit <= 0:
            return []
        return [dict(item) for item in self._input_shadow[:limit]]

    def dequeue_user_input_nowait(self) -> _InputQueueItem | None:
        try:
            item = self._input_queue.get_nowait()
        except Exception:
            return None
        if self._input_shadow:
            self._input_shadow.pop(0)
        return {
            "status": str(item.get("status") or "agent"),
            "text": str(item.get("text") or ""),
            "type": str(item.get("type") or "default"),
        }

    def clear_user_input_queue(self) -> int:
        removed = 0
        while True:
            try:
                self._input_queue.get_nowait()
                removed += 1
            except Exception:
                break
        self._input_shadow.clear()
        return removed

    def request_cancel_tool(self) -> None:
        """Request cancellation for the currently running tool."""
        self._cancel_evt.set()

    def subscribe(self, queue: asyncio.Queue[EventPayload]) -> bool:
        return self._pubsub.subscribe(queue)

    def get_tool_class(self, name: str):
        return self._tool_registry.get_tool_class(name)

    def get_last_usage(self, ctx: Context) -> dict[str, Any] | None:
        """Return the newest assistant usage payload from context history."""
        try:
            for item in reversed(list(getattr(ctx, "history", []))):
                if isinstance(item, Message) and item.role == ROLE_ASSISTANT and isinstance(item.usage, dict):
                    return item.usage
        except Exception:
            return None
        return None

    def get_last_total_tokens(self, ctx: Context) -> int | None:
        usage = self.get_last_usage(ctx)
        if not isinstance(usage, dict):
            return None
        total = usage.get("total_tokens")
        if isinstance(total, int):
            return total
        return None

    def get_tool_call_deltas(self) -> list[ToolCallDeltaEvent]:
        return list(self._tool_call_deltas.values())

    def get_tool_call_delta(self, delta_id: str) -> ToolCallDeltaEvent | None:
        return self._tool_call_deltas.get(str(delta_id))

    def _accumulate_tool_call_delta(self, payload: ToolCallDeltaEvent) -> None:
        delta_id = str(payload.get("delta_id") or "")
        if not delta_id:
            return
        prev = self._tool_call_deltas.get(delta_id)
        if prev is None:
            base: ToolCallDeltaEvent = {
                "type": "tool_call_delta",
                "delta_id": delta_id,
                "tool": "",
                "args": "",
            }
            prev = base
        tool_delta = str(payload.get("tool") or "")
        args_delta = str(payload.get("args") or "")
        if tool_delta:
            prev["tool"] = str(prev.get("tool") or "") + tool_delta
        if args_delta:
            prev["args"] = str(prev.get("args") or "") + args_delta
        idx = payload.get("index")
        if idx is not None:
            prev["index"] = idx
        tcid = payload.get("tool_call_id")
        if tcid:
            prev["tool_call_id"] = tcid
        preview = payload.get("preview")
        if preview:
            prev["preview"] = preview
        self._tool_call_deltas[delta_id] = prev

    def unsubscribe(self, queue: asyncio.Queue[EventPayload]) -> bool:
        return self._pubsub.unsubscribe(queue)

    async def _publish_to_subscribers(self, payload: EventPayload) -> None:
        if payload.get("type") == "tool_call_delta":
            self._accumulate_tool_call_delta(cast(ToolCallDeltaEvent, payload))
        await self._debug_put(payload)

    def _collect_tool_schemas(self, ctx: Context) -> list[_ToolSchema]:
        local_schemas = cast(list[_ToolSchema], self._tool_registry.get_schemas())
        merged = self._mcp_adapter.merge_tool_schemas(ctx, cast(list[dict[str, Any]], local_schemas))
        return cast(list[_ToolSchema], merged)

    async def _execute_remote_tool(self, ctx: Context, name: str, args: JsonObject) -> ToolResult:
        return await self._mcp_adapter.execute_remote_tool(ctx, name, args)

    def set_tool_policy(self, policy: Callable[[str, JsonObject], Awaitable[bool]]) -> None:
        self._tool_policy = policy

    async def aclose(self):
        return None

    async def _litellm_acreate(
        self,
        messages: ApiMessagesInput,
        *,
        stream: bool = False,
        tools: list | None = None,
        tool_choice: str | dict | None = None,
        tool_stream: bool = False,
        debug: bool = False,
        sse_debug: bool = False,
        **extra,
    ) -> dict | AsyncGenerator[object, None]:
        api_messages = _to_api_messages(messages)
        return await self._transport.acreate(
            api_messages,
            stream=stream,
            tools=tools,
            tool_choice=tool_choice,
            tool_stream=tool_stream,
            debug=debug,
            sse_debug=sse_debug,
            **extra,
        )

    async def _debug_put(self, payload: EventPayload) -> None:
        if self._debug_loop:
            try:
                print(f"[debug][publish] {payload}")
            except Exception:
                pass
        await self._pubsub.publish(payload)

    def _emit_stream_transition_events(
        self,
        tr: StreamTransitionUnion,
    ) -> list[StreamOutEvent]:
        if tr.kind == "assistant_delta":
            return [ev_assistant_delta(tr.payload.get("text", ""), tr.payload.get("model"))]
        if tr.kind == "assistant_reasoning_delta":
            return [ev_assistant_reasoning_delta(tr.payload.get("text", ""), tr.payload.get("model"))]
        if tr.kind == "assistant_done":
            return []
        return []

    async def run_queues(self, ctx: Context) -> None:
        """Run the agent loop continuously and broadcast events to subscribers."""
        model_name = None
        try:
            if hasattr(ctx, "set_tool_registry"):
                ctx.set_tool_registry(self._tool_registry)
        except Exception:
            pass
        try:
            start_global_litellm_prewarm(blocking=False)
        except Exception:
            pass

        async def _wait_next_input() -> _InputQueueItem:
            queued = await self._input_queue.get()
            if self._input_shadow:
                self._input_shadow.pop(0)
            try:
                result = queued
            except Exception:
                result = None
            if isinstance(result, dict):
                return {
                    "status": str(result.get("status") or "agent"),
                    "text": str(result.get("text") or ""),
                    "type": str(result.get("type") or "default"),
                }
            return {"status": "agent", "text": str(result) if result is not None else "", "type": "default"}

        while True:
            request_result = await _wait_next_input()
            req_t0 = time.perf_counter()
            status = request_result.get("status", "agent")
            user_input = request_result.get("text", "")
            if status != "agent":
                continue
            if not user_input.strip():
                continue
            persist_task: Optional[asyncio.Task] = None
            try:
                persist_coro = getattr(ctx, "aensure_persisted", None)
                if callable(persist_coro):
                    persist_task = asyncio.create_task(persist_coro())
            except Exception:
                persist_task = None
            try:
                tool_contents = []
                for tool_name in self._tool_registry.list_tools():
                    tool_class = self._tool_registry.get_tool_class(tool_name)
                    if not tool_class:
                        continue
                    try:
                        extra = tool_class.user_input_hook()
                    except Exception:
                        extra = None
                    if extra is not None:
                        tool_contents.append(extra)
                if self._debug_loop:
                    await self._debug_put(ev_debug_latency(stage="hooks", ms=round((time.perf_counter() - req_t0) * 1000, 1)))

                # Wrap user input in UserContent.
                user_content = UserContent(
                    user_name=self.cfg.agent_config.user_name,
                    email=self.cfg.agent_config.email,
                    date=_dt.datetime.now().astimezone(),
                    content=user_input,
                    tool_contents=tool_contents or None,
                )
                if persist_task is not None:
                    await persist_task
                ctx.append_message(Message(role=ROLE_USER, content=user_content))
                if self._debug_loop:
                    await self._debug_put(ev_debug_latency(stage="append", ms=round((time.perf_counter() - req_t0) * 1000, 1)))
            except Exception as e:
                traceback.print_exc()
                error_msg = str(e)
                await self._debug_put(ev_error(error_msg, exception_type=type(e).__name__, traceback_text=traceback.format_exc()))
                await self._debug_put(ev_assistant_final(f"Error: {error_msg}"))
                continue

            for _ in range(self.cfg.agent_config.max_steps):
                self._tool_call_deltas.clear()
                if self._cancel_evt.is_set():
                    self._cancel_evt.clear()
                    await self._debug_put(ev_run_aborted("cancelled"))
                    await self._debug_put(ev_assistant_final(""))
                    break
                # Directly pass ctx to client instead of building messages first
                stream_state = TurnStreamState()
                try:
                    _agen_coro = self._litellm_acreate(
                        ctx,
                        stream=True,
                        tools=self._collect_tool_schemas(ctx),
                        tool_choice="auto",
                        tool_stream=True,
                        debug=self.cfg.provider.debug_request,
                        sse_debug=self.cfg.provider.debug_sse,
                    )  # type: ignore[assignment]
                    await self._debug_put(ev_message_sent())
                    if self._debug_loop:
                        await self._debug_put(
                            ev_debug_latency(stage="message_sent", ms=round((time.perf_counter() - req_t0) * 1000, 1))
                        )
                    agen = await _agen_coro  # type: ignore[assignment]
                except Exception as e:
                    traceback.print_exc()
                    # Emit error event and wait for next user input.
                    error_msg = f"API call failed: {str(e)}"
                    await self._debug_put(ev_error(error_msg, exception_type=type(e).__name__, traceback_text=traceback.format_exc()))
                    await self._debug_put(ev_assistant_final(f"Error: {error_msg}"))
                    break
                
                if self._cancel_evt.is_set():
                    self._cancel_evt.clear()
                    await self._debug_put(ev_run_aborted("cancelled"))
                    await self._debug_put(ev_assistant_final(""))
                    break
                try:
                    async for ev in agen:  # type: ignore[attr-defined]
                        if self._cancel_evt.is_set():
                            self._cancel_evt.clear()
                            await self._debug_put(ev_run_aborted("cancelled"))
                            await self._debug_put(ev_assistant_final(""))
                            break
                        for signal in ingest_stream_event(ev):
                            if self.cfg.provider.debug_tools and signal.type == "tool_call_delta" and isinstance(ev, dict):
                                debug_event: ToolCallDebugEvent = ev_tool_call_debug_delta(cast(ToolCallDeltaPayload, ev))
                                await self._debug_put(debug_event)
                            if signal.type == "tool_call_delta":
                                raw_index = signal.payload.get("output_index")
                                raw_tcid = signal.payload.get("tool_call_id")
                                if raw_index is not None:
                                    delta_id = f"idx:{raw_index}"
                                elif raw_tcid:
                                    delta_id = f"id:{raw_tcid}"
                                else:
                                    delta_id = ""
                                if not delta_id:
                                    continue
                                await self._debug_put(
                                    ev_tool_call_delta(
                                        tool=str(signal.payload.get("name") or ""),
                                        args=str(signal.payload.get("arguments") or ""),
                                        delta_id=delta_id,
                                        index=cast(Optional[int], raw_index),
                                        tool_call_id=cast(Optional[str], raw_tcid),
                                    )
                                )
                            tr = reduce_stream_signal(stream_state, signal)
                            if tr.kind == "tool_call_delta_dropped":
                                if self.cfg.provider.debug_tools:
                                    dropped_event: ToolCallDebugEvent = ev_tool_call_debug_warning(
                                        "tool_call_delta without output_index/tool_call_id; fragment dropped"
                                    )
                                    await self._debug_put(dropped_event)
                                continue
                            if tr.kind == "tool_calls_done" and self.cfg.provider.debug_tools:
                                assembled = build_assembled_debug(stream_state)
                                assembled_event: ToolCallDebugEvent = ev_tool_call_debug_assembled(assembled)
                                await self._debug_put(assembled_event)
                            for out_ev in self._emit_stream_transition_events(tr):
                                await self._debug_put(out_ev)
                except Exception as e:
                    # Stream processing failed; emit error and break the turn loop.
                    error_msg = f"Stream processing failed: {str(e)}"
                    await self._debug_put(ev_error(error_msg, exception_type=type(e).__name__, traceback_text=traceback.format_exc()))
                    await self._debug_put(ev_assistant_final(f"Error: {error_msg}"))
                    break

                final = stream_state.final
                reasoning = stream_state.reasoning
                reasoning_details = stream_state.reasoning_details
                usage_data = stream_state.usage_data
                if stream_state.model_name is not None:
                    model_name = stream_state.model_name
                tool_records: list[ToolRecord] = []
                if stream_state.tool_phase and stream_state.tool_calls_by_idx:
                    tool_records = build_tool_records(stream_state)
                if tool_records:
                    request_new_input = await self._tool_runtime.process_tool_records(
                        ctx=ctx,
                        tool_records=tool_records,
                        final=final,
                        reasoning=reasoning,
                        reasoning_details=reasoning_details,
                        usage_data=usage_data,
                        model_name=model_name,
                    )
                    if request_new_input:
                        break

                if not tool_records:
                    if final:
                        await self._debug_put(
                            ev_assistant_final(
                                final,
                                usage_data=usage_data,
                                reasoning=reasoning if reasoning else None,
                            )
                        )
                        try:
                            ctx.append_message(
                                Message(
                                    role=ROLE_ASSISTANT,
                                    content=final,
                                    usage=usage_data,
                                    reasoning_content=reasoning if reasoning else None,
                                    model_name=model_name,
                                    extra={"reasoning_details": reasoning_details} if reasoning_details else None,
                                )
                            )
                        except Exception:
                            traceback.print_exc()
                        break
