"""
* 文件名: sync_namespace
* 作者: JQQ
* 创建日期: 2025/9/29
* 最后修改日期: 2025/9/29
* 版权: 2023 JQQ. All rights reserved.
* 依赖: socketio, loguru, pydantic
* 描述: 同步版本SMCP协议Namespace实现 / Synchronous SMCP protocol Namespace implementation
"""

import copy
import threading
from typing import Any, cast

from pydantic import TypeAdapter

from a2c_smcp.exceptions import SMCPNamespaceError
from a2c_smcp.server.sync_auth import SyncAuthenticationProvider
from a2c_smcp.server.sync_base import SyncBaseNamespace
from a2c_smcp.server.types import OFFICE_ID, SID
from a2c_smcp.server.utils import get_all_sessions_in_office
from a2c_smcp.smcp import (
    CANCEL_TOOL_CALL_NOTIFICATION,
    ENTER_OFFICE_NOTIFICATION,
    GET_BLOB_EVENT,
    GET_CONFIG_EVENT,
    GET_DESKTOP_EVENT,
    GET_RESOURCES_EVENT,
    GET_SKILL_EVENT,
    GET_SKILLS_EVENT,
    GET_TOOLS_EVENT,
    LEAVE_OFFICE_NOTIFICATION,
    SMCP_NAMESPACE,
    TOOL_CALL_EVENT,
    UPDATE_CONFIG_NOTIFICATION,
    UPDATE_DESKTOP_NOTIFICATION,
    UPDATE_SKILLS_NOTIFICATION,
    UPDATE_TOOL_LIST_NOTIFICATION,
    AgentCallData,
    EnterOfficeNotification,
    EnterOfficeReq,
    ErrorPayload,
    GetBlobReq,
    GetBlobRet,
    GetComputerConfigReq,
    GetComputerConfigRet,
    GetDeskTopReq,
    GetDeskTopRet,
    GetResourcesReq,
    GetResourcesRet,
    GetSkillReq,
    GetSkillRet,
    GetSkillsReq,
    GetSkillsRet,
    GetToolsReq,
    GetToolsRet,
    LeaveOfficeNotification,
    LeaveOfficeReq,
    ListRoomReq,
    ListRoomRet,
    SessionInfo,
    UpdateComputerConfigReq,
    UpdateMCPConfigNotification,
    build_computer_not_found_error,
    is_protocol_error_payload,
)
from a2c_smcp.utils.logger import get_logger

logger = get_logger("server")

# 在途断连守卫的哨兵：与任何合法 Computer 响应（dict / None / flat ErrorPayload）都不相等，
# 用于把「目标在途断连」与「Computer 正常返回 None」区分开（#100 Phase 1，sync）。
# Sentinel for the in-flight disconnect guard: distinct from any valid Computer response.
_DISCONNECTED: object = object()


class SyncSMCPNamespace(SyncBaseNamespace):
    """
    同步SMCP命名空间，处理SMCP相关事件（同步）
    Synchronous Socket.IO namespace for handling SMCP-related events
    """

    def __init__(self, auth_provider: SyncAuthenticationProvider) -> None:
        """
        初始化SMCP命名空间（同步）
        Initialize SMCP namespace (sync)
        """
        super().__init__(namespace=SMCP_NAMESPACE, auth_provider=auth_provider)
        # 在途 ``client:*`` 调用的「断连信号」登记表，以**目标 Computer SID** 为 key（#100 Phase 1，async 镜像）。
        # 同步版 ``self.call`` 阻塞当前 worker 线程，故由独立 daemon 子线程跑 ``call``、主线程等共享事件；
        # registry 跨线程读写（worker 线程登记/注销 vs ``on_disconnect`` 线程触发），用 ``_inflight_lock`` 守护。
        # Per-target in-flight disconnect signals (#100 Phase 1, sync mirror): target Computer SID -> set of Events.
        self._inflight_disconnect_signals: dict[SID, set[threading.Event]] = {}
        self._inflight_lock = threading.Lock()

    def on_disconnect(self, sid: SID) -> None:
        """断连处理（同步）：先走 base 的 name/room 清理，再唤醒以 ``sid`` 为目标的全部在途 ``client:*`` 调用（#100 Phase 1）。
        On disconnect: run base name/room cleanup first, then wake every in-flight call targeting ``sid``.

        **先 super() 再 fire**（与 async 对齐，关闭 TOCTOU 微窗）：``_unregister_name`` 删除 name 映射后，窗内并发新调用的
        TOCTOU 复查 ``get_sid_by_name`` 读到 None 直接短路 404，不会错过 fire 退化为满 timeout。信号按 sid 登记、``super()``
        不触碰信号 registry，故 fire 置于其后对已登记信号零损失；``_fire`` 持 ``_inflight_lock`` 而 ``super()`` 不持锁，无死锁。
        Super-then-fire (mirrors async) closes the TOCTOU window; already-registered signals still wake.
        """
        super().on_disconnect(sid)
        self._fire_inflight_disconnect_signals(sid)

    def _fire_inflight_disconnect_signals(self, sid: SID) -> None:
        """唤醒以 ``sid`` 为目标的全部在途断连信号（幂等）。Wake all in-flight signals targeting ``sid`` (idempotent)."""
        with self._inflight_lock:
            signals = list(self._inflight_disconnect_signals.get(sid, ()))
        for ev in signals:
            ev.set()

    def _register_inflight_signal(self, computer_sid: SID) -> threading.Event:
        """登记并返回一个针对 ``computer_sid`` 的在途断连信号。Register and return a fresh disconnect signal."""
        ev = threading.Event()
        with self._inflight_lock:
            self._inflight_disconnect_signals.setdefault(computer_sid, set()).add(ev)
        return ev

    def _discard_inflight_signal(self, computer_sid: SID, ev: threading.Event) -> None:
        """注销在途信号；set 空时删除 key（防 dict 泄漏）。Unregister a signal; drop the key when the set empties."""
        with self._inflight_lock:
            bucket = self._inflight_disconnect_signals.get(computer_sid)
            if bucket is not None:
                bucket.discard(ev)
                if not bucket:
                    self._inflight_disconnect_signals.pop(computer_sid, None)

    def enter_room(self, sid: SID, room: OFFICE_ID, namespace: str | None = None) -> None:
        """
        客户端加入房间，维护session中的sid/name/office_id字段（同步）
        Client joins room, maintain sid/name/office_id in session (sync)
        """
        session = self.get_session(sid)

        if session.get("sid") != sid:
            session["sid"] = sid
        if not session.get("name"):
            session["name"] = f"{session.get('role', 'unknown')}_{sid[:6]}"

        if session.get("role") == "agent":
            if session.get("office_id") and session.get("office_id") != room:
                logger.error(f"Agent sid: {sid} already in room: {session.get('office_id')}, can't join room: {room}")
                raise ValueError("Agent sid already in room")
            elif not session.get("office_id"):
                for participant_sid, _participant_eio_sid in self.server.manager.get_participants(SMCP_NAMESPACE, room):
                    participant_session = self.get_session(participant_sid)
                    if participant_session.get("role") == "agent":
                        raise ValueError("Agent already in room")
            else:
                logger.warning(
                    f"Agent sid: {sid} already in room: {session.get('office_id')}. 正在重复加入房间",
                )
                return
        else:
            if session.get("office_id") and (past_room := session.get("office_id")) != room:
                self.leave_room(sid, past_room)
            elif session.get("office_id") == room:
                logger.warning(
                    f"Computer sid: {sid} already in room: {session.get('office_id')}. 正在重复加入房间",
                )
                return

            # 检查房间内是否已有同名的Computer
            # Check if there's already a Computer with the same name in the room
            computer_name = session.get("name")
            if computer_name:
                for participant_sid, _participant_eio_sid in self.server.manager.get_participants(SMCP_NAMESPACE, room):
                    if participant_sid == sid:
                        continue
                    participant_session = self.get_session(participant_sid)
                    if participant_session.get("role") == "computer" and participant_session.get("name") == computer_name:
                        raise ValueError(f"Computer with name '{computer_name}' already exists in room '{room}'")

        super().enter_room(sid, room)
        session["office_id"] = room
        self.save_session(sid, session)

        # 注册name到sid的映射
        # Register name-to-sid mapping
        self._register_name(session["name"], sid)

        # 根据角色发送不同的通知 / Send different notifications based on role
        notification_data: EnterOfficeNotification = {"office_id": room}
        if session.get("role") == "computer":
            notification_data["computer"] = session.get("name")
        else:
            notification_data["agent"] = session.get("name")

        self.emit(
            ENTER_OFFICE_NOTIFICATION,
            notification_data,
            skip_sid=sid,
            room=room,
        )

    def leave_room(self, sid: SID, room: OFFICE_ID, namespace: str | None = None) -> None:
        """
        在离开房间之前发布离开消息（同步）
        Publish leave message before leaving room (sync)
        """
        session = self.get_session(sid)

        # 构建离开通知，使用name而不是sid
        # Build leave notification using name instead of sid
        client_name = session.get("name", "")
        notification = (
            LeaveOfficeNotification(office_id=room, computer=client_name)
            if session.get("role") == "computer"
            else LeaveOfficeNotification(office_id=room, agent=client_name)
        )
        self.emit(LEAVE_OFFICE_NOTIFICATION, notification, skip_sid=sid, room=room)

        # 注销name映射
        # Unregister name mapping
        self._unregister_name(sid)

        if "office_id" in session:
            del session["office_id"]
        self.save_session(sid, session)

        super().leave_room(sid, room)

    def on_server_join_office(self, sid: str, data: EnterOfficeReq) -> tuple[bool, str | None]:
        """
        同步：Computer/Agent加入房间
        Sync: Computer or Agent joins room
        """
        role_info = TypeAdapter(EnterOfficeReq).validate_python(data)
        expected_role = role_info["role"]

        session = self.get_session(sid)
        backup_session = copy.deepcopy(session)

        try:
            if session.get("role") and session["role"] != expected_role:
                return False, f"Role mismatch, expected {expected_role}, but {session['role']} use this sid exists"

            session["role"] = expected_role
            session["name"] = role_info["name"]
            self.save_session(sid, session)

            self.enter_room(sid, role_info["office_id"])
            return True, None
        except Exception as e:
            self.save_session(sid, backup_session)
            return False, f"Internal server error: {str(e)}"

    def on_server_leave_office(self, sid: str, data: LeaveOfficeReq) -> tuple[bool, str | None]:
        """
        同步：Computer/Agent离开房间
        Sync: Computer or Agent leaves room
        """
        try:
            self.leave_room(sid, data["office_id"])
            return True, None
        except Exception as e:
            return False, f"Internal server error: {str(e)}"

    def on_server_tool_call_cancel(self, sid: str, data: AgentCallData) -> None:
        """
        同步：广播取消ToolCall到房间内的其他成员
        Sync: broadcast tool call cancellation to other members in the room
        """
        session = self.get_session(sid)
        if session["role"] != "agent":
            raise SMCPNamespaceError("目前仅支持Agent调用取消ToolCall的操作")

        agent_call = TypeAdapter(AgentCallData).validate_python(data)
        if session.get("name") != agent_call["agent"]:
            raise SMCPNamespaceError("取消工具调用的广播仅可以由对应Agent发出")

        # 广播到 office 房间，而不是 Agent 的私有房间 / Broadcast to office room, not Agent's private room
        office_id = session.get("office_id")
        self.emit(
            CANCEL_TOOL_CALL_NOTIFICATION,
            agent_call,
            room=office_id,
            skip_sid=sid,
        )

    def on_server_update_config(self, sid: str, data: UpdateComputerConfigReq) -> None:
        """
        同步：广播更新MCP配置
        Sync: broadcast MCP config update
        """
        session = self.get_session(sid)
        if session["role"] != "computer":
            raise SMCPNamespaceError("目前仅支持Computer调用更新MCP配置的操作")

        update_config = TypeAdapter(UpdateComputerConfigReq).validate_python(data)
        self.emit(
            UPDATE_CONFIG_NOTIFICATION,
            UpdateMCPConfigNotification(computer=update_config["computer"]),
            room=session["office_id"],
            skip_sid=sid,
        )

    def on_server_update_tool_list(self, sid: str, data: UpdateComputerConfigReq) -> None:
        """
        同步：广播工具列表更新
        Sync: broadcast tool list update
        """
        session = self.get_session(sid)
        if session["role"] != "computer":
            raise SMCPNamespaceError("目前仅支持Computer上报工具列表变更")

        update_req = TypeAdapter(UpdateComputerConfigReq).validate_python(data)

        self.emit(
            UPDATE_TOOL_LIST_NOTIFICATION,
            {"computer": update_req["computer"]},
            room=session.get("office_id"),
            skip_sid=sid,
        )

    def on_client_tool_call(self, sid: str, data: dict) -> dict | ErrorPayload:
        """
        同步：响应工具调用，使用 call 方法等待 Computer 返回结果
        Sync: respond to tool call, use call method to wait for Computer response

        经 :meth:`_relay_client_call` 统一 SID 解析 + office/role 隔离 + flat ErrorPayload(404) 透传（#99：
        目标 Computer 不存在不再 raise ValueError 致 Agent ``call`` 静默超时，与其余 ``client:*`` 事件对齐）。
        仅 Agent 可发起工具调用，故委托前保留角色校验。
        Routed via ``_relay_client_call`` (#99: missing Computer → flat ErrorPayload(404), not an uncaught
        ValueError that times the Agent out). Only an Agent may invoke a tool, hence the role check before relay.
        """
        session = self.get_session(sid)
        if session["role"] != "agent":
            raise SMCPNamespaceError("目前仅支持Agent调用工具")

        # tool_call 响应是 Computer 回传的 CallToolResult 原样 dict（无固定 A2C TypedDict），ret_adapter 用
        # TypeAdapter(dict) 原样透传；per-request timeout 若提供则透传（缺省回落 socketio 默认，保持同步旧行为）。
        # tool_call's response is the Computer's raw CallToolResult dict; pass through timeout when present.
        tool_call = TypeAdapter(dict).validate_python(data)
        return cast(
            "dict | ErrorPayload",
            self._relay_client_call(
                sid,
                tool_call,
                TOOL_CALL_EVENT,
                TypeAdapter(dict),
                timeout=tool_call.get("timeout"),
            ),
        )

    def on_client_get_tools(self, sid: str, data: GetToolsReq) -> GetToolsRet | ErrorPayload:
        """
        同步：获取指定 Computer 的工具列表（``client:get_tools``）/ Sync: get tool list of specified Computer.

        经 :meth:`_relay_client_call` 统一 isolation + flat ErrorPayload 透传（v0.2.2：所有 ``client:*``
        ack 统一 flat ErrorPayload，旧路由非豁免）。
        """
        return cast(
            "GetToolsRet | ErrorPayload",
            self._relay_client_call(sid, data, GET_TOOLS_EVENT, TypeAdapter(GetToolsRet)),
        )

    def on_client_get_desktop(self, sid: str, data: GetDeskTopReq) -> GetDeskTopRet | ErrorPayload:
        """
        同步：获取指定 Computer 的桌面视图（``client:get_desktop``）/ Sync: get desktop view from Computer.

        要求 Agent 与 Computer 同一 office；经 :meth:`_relay_client_call` 统一 isolation + flat ErrorPayload 透传。
        """
        return cast(
            "GetDeskTopRet | ErrorPayload",
            self._relay_client_call(sid, data, GET_DESKTOP_EVENT, TypeAdapter(GetDeskTopRet)),
        )

    def _relay_client_call(
        self,
        sid: str,
        data: Any,
        event: str,
        ret_adapter: TypeAdapter[Any],
        timeout: float | None = None,
    ) -> Any:
        """同步版通用 ``client:*`` 事件路由 / Sync mirror of ``_relay_client_call``.

        统一收敛 office/role 隔离校验、Computer SID 解析、flat ErrorPayload 透传。
        Unifies office/role isolation, Computer SID lookup, and flat-ErrorPayload pass-through.

        Computer 名未命中 → 回 flat ``ErrorPayload(404)``（#92，error-handling.md §20 + §78），**不**抛
        未捕获异常（同步 socketio 下抛异常会杀线程、不回 ack，致 Agent ``call`` 静默超时）。office/role
        隔离失败仍 raise ``SMCPNamespaceError``（安全：跨房间不泄露 Computer 存在性，见 exceptions.py）。
        Computer name miss → flat ``ErrorPayload(404)`` (no uncaught raise); isolation failures still raise.

        ``timeout`` 仅 ``tool_call`` 透传 per-request 超时；其余 ``client:*`` 事件留 None → 走 socketio 默认。
        ``timeout`` is only passed through for ``tool_call``; other events leave it None (socketio default).

        协议依据 / Protocol: events.md 各 ``client:*`` 事件 + error-handling.md flat ErrorPayload.
        """
        computer_name = data["computer"]
        computer_sid = self.get_sid_by_name(computer_name)
        if not computer_sid:
            return build_computer_not_found_error(computer_name)

        session = self.get_session(computer_sid)
        if session["role"] != "computer":
            raise SMCPNamespaceError(f"目前仅支持 Computer 响应 {event} / target SID is not a Computer")

        agent_session = self.get_session(sid)
        if agent_session is None:
            # 发起者（Agent）飞行中断连：会话已不存在。显式 raise 替代 None.get 的 AttributeError
            # （对齐 #31）；协议许可 Server MAY 静默不 ack、不新增错误码（v0.2.2）。
            raise SMCPNamespaceError(f"发起者会话不存在（可能已断连）：{event} / originator session gone")
        if session.get("office_id") != agent_session.get("office_id"):
            raise SMCPNamespaceError(
                f"跨房间访问被拒绝：{event} 仅限同一 office / cross-office {event} access denied",
            )

        # tool_call 透传 per-request timeout；其余 client:* 事件 timeout=None → 用 socketio 默认
        # （勿对默认事件传 timeout=None，socketio 下会变成永久等待而非默认 60s）。
        # Pass tool_call's per-request timeout; other client:* events leave it None (socketio default).
        call_kwargs: dict[str, Any] = {"to": computer_sid, "namespace": SMCP_NAMESPACE}
        if timeout is not None:
            call_kwargs["timeout"] = timeout

        client_response = self._call_with_disconnect_guard(event, data, computer_name, computer_sid, call_kwargs)
        if client_response is _DISCONNECTED:
            # 目标在途断连：Computer 中途消失 == 不存在，回 flat ErrorPayload(404)（与 #92/#99 同一 payload）。
            # Target disconnected mid-flight: a vanished Computer == not found → flat ErrorPayload(404).
            return build_computer_not_found_error(computer_name)
        if is_protocol_error_payload(client_response):
            return TypeAdapter(ErrorPayload).validate_python(client_response)
        return ret_adapter.validate_python(client_response)

    def _call_with_disconnect_guard(
        self,
        event: str,
        data: Any,
        computer_name: str,
        computer_sid: SID,
        call_kwargs: dict[str, Any],
    ) -> Any:
        """在途断连守卫（#100 Phase 1，sync）：把阻塞的 ``self.call`` 丢到 daemon 子线程，主线程等「完成 OR 断连信号」.

        同步 socketio ``self.call`` 阻塞当前线程、其等待原语在 ``call()`` 内部不可达，无法外部中断；故子线程跑
        ``call``、主线程 ``wait`` 一个**既被完成路径又被 ``on_disconnect`` 触发**的共享事件。三结局严格区分：
          - 子线程正常返回 → 透传结果（``ret_adapter`` 在调用方校验）；
          - 子线程抛异常（如真超时 ``TimeoutError``）→ 在主线程**原样重抛**（绝不转 404）；
          - 断连先到（box 仍空）→ 返回 ``_DISCONNECTED`` 哨兵 → 调用方回 404。
        断连时被遗弃的子线程仍阻塞在 ``call`` 内直到其自身 timeout 退出（有界 daemon 僵尸，仅断连这一罕见事件触发）。
        **容量评估**：每个在途调用（含 happy path）多占 1 个 daemon 线程（原 worker 阻塞在 ``wait``）；worst-case
        线程上限随**并发在途调用数**线性增长，断连时再叠加「等各自 ``timeout`` 退出」的僵尸 runner。signaling 负载下可接受，
        高并发场景如需收敛可另开 issue 引入线程池/信号量（不在本 issue 范围）。
        Sync in-flight disconnect guard: run blocking ``self.call`` in a daemon thread, wait a shared event set by
        either completion or ``on_disconnect``; orphaned thread exits at its own timeout (bounded daemon zombie).
        Capacity note: each in-flight call costs one extra daemon thread; worst-case thread count grows linearly
        with concurrent in-flight calls (plus zombies awaiting their own timeout on disconnect).
        """
        disconnect_ev = self._register_inflight_signal(computer_sid)
        try:
            # TOCTOU 复查：解析 SID 与登记之间目标可能已断连（信号永不会被 fire）。用 ``get_sid_by_name`` 复查
            # （``on_disconnect`` → base ``_unregister_name`` 清掉 name 映射）关闭该窗口。
            # TOCTOU re-check: target may have died between SID resolve and registration → short-circuit to 404.
            if disconnect_ev.is_set() or self.get_sid_by_name(computer_name) is None:
                return _DISCONNECTED

            # ``disconnect_ev`` 一事二用：完成路径由 ``_runner`` 在 finally 触发，断连路径由 ``on_disconnect`` 触发，
            # 主线程 ``wait`` 二者之一。醒来后查 box 区分（``value`` 优先于断连，确保真 ack 不被误判为断连）。
            # ``disconnect_ev`` doubles as completion + disconnect signal; the box disambiguates after wakeup.
            box: dict[str, Any] = {}

            def _runner() -> None:
                try:
                    box["value"] = self.call(event, data, **call_kwargs)
                except BaseException as exc:  # noqa: BLE001 捕获后在调用方线程重抛 / re-raised on caller thread
                    box["error"] = exc
                finally:
                    disconnect_ev.set()

            threading.Thread(target=_runner, name=f"a2c-relay-{event}", daemon=True).start()
            disconnect_ev.wait()
            if "value" in box:
                return box["value"]
            if "error" in box:
                raise box["error"]
            return _DISCONNECTED
        finally:
            self._discard_inflight_signal(computer_sid, disconnect_ev)

    def on_client_get_config(self, sid: str, data: GetComputerConfigReq) -> GetComputerConfigRet | ErrorPayload:
        """同步：透明转发 ``client:get_config`` 至目标 Computer，返回其 MCP 配置 / Sync relay of ``client:get_config``.

        返回协议已定义的 ``GetComputerConfigRet``（``servers`` 占位符原样，解析后密钥不外传）；
        经 :meth:`_relay_client_call` 统一 isolation + flat ErrorPayload 透传。
        协议依据 / Protocol: events.md §client:get_config；data-structures.md §GetComputerConfigRet.
        """
        return cast(
            "GetComputerConfigRet | ErrorPayload",
            self._relay_client_call(sid, data, GET_CONFIG_EVENT, TypeAdapter(GetComputerConfigRet)),
        )

    def on_client_get_resources(self, sid: str, data: GetResourcesReq) -> GetResourcesRet | ErrorPayload:
        """
        同步：透明转发 ``client:get_resources`` 至目标 Computer（含 cursor 翻页）。
        Sync: relay ``client:get_resources`` to the target Computer (with cursor pagination).
        """
        return cast(
            "GetResourcesRet | ErrorPayload",
            self._relay_client_call(sid, data, GET_RESOURCES_EVENT, TypeAdapter(GetResourcesRet)),
        )

    def on_client_get_skills(self, sid: str, data: GetSkillsReq) -> GetSkillsRet | ErrorPayload:
        """同步：透明转发 ``client:get_skills`` / Sync relay of ``client:get_skills``."""
        return cast(
            "GetSkillsRet | ErrorPayload",
            self._relay_client_call(sid, data, GET_SKILLS_EVENT, TypeAdapter(GetSkillsRet)),
        )

    def on_client_get_skill(self, sid: str, data: GetSkillReq) -> GetSkillRet | ErrorPayload:
        """同步：透明转发 ``client:get_skill`` / Sync relay of ``client:get_skill``."""
        return cast(
            "GetSkillRet | ErrorPayload",
            self._relay_client_call(sid, data, GET_SKILL_EVENT, TypeAdapter(GetSkillRet)),
        )

    def on_client_get_blob(self, sid: str, data: GetBlobReq) -> GetBlobRet | ErrorPayload:
        """同步：透明转发 ``client:get_blob`` / Sync relay of ``client:get_blob``.

        Server **不**重组 blob，按 ``computer`` 逐 ack 透传（与 async 一致）.
        Server does NOT reassemble; each chunk is a separate ack (mirrors async).
        """
        return cast(
            "GetBlobRet | ErrorPayload",
            self._relay_client_call(sid, data, GET_BLOB_EVENT, TypeAdapter(GetBlobRet)),
        )

    def on_server_update_desktop(self, sid: str, data: UpdateComputerConfigReq) -> None:
        """
        同步：将事件广播至对应的房间内其他参与者，通知桌面刷新
        Sync: broadcast to others in the room to notify desktop update

        Args:
            sid (str): 发起者ID，应为Computer / Initiator ID, should be Computer
            data (UpdateComputerConfigReq): 载荷复用 UpdateConfigReq，仅需 computer 标识
        """
        session = self.get_session(sid)
        if session["role"] != "computer":
            raise SMCPNamespaceError("目前仅支持Computer上报桌面刷新")

        update_req = TypeAdapter(UpdateComputerConfigReq).validate_python(data)
        self.emit(
            UPDATE_DESKTOP_NOTIFICATION,
            {"computer": update_req["computer"]},
            room=session.get("office_id"),
            skip_sid=sid,
        )

    def on_server_update_skills(self, sid: str, data: UpdateComputerConfigReq) -> None:
        """同步：``server:update_skills`` → ``notify:update_skills`` 广播.

        Sync mirror of ``on_server_update_skills``; broadcasts SKILL set change to office.
        协议依据 / Protocol: events.md §server:update_skills / §notify:update_skills.
        """
        session = self.get_session(sid)
        if session["role"] != "computer":
            raise SMCPNamespaceError("目前仅支持 Computer 上报 SKILL 变更 / only Computers may emit update_skills")

        update_req = TypeAdapter(UpdateComputerConfigReq).validate_python(data)
        self.emit(
            UPDATE_SKILLS_NOTIFICATION,
            {"computer": update_req["computer"]},
            room=session.get("office_id"),
            skip_sid=sid,
        )

    def on_server_list_room(self, sid: str, data: ListRoomReq) -> ListRoomRet:
        """
        同步：列出指定房间内的所有会话信息。Agent可以通过此事件查询房间内的所有Computer和Agent。
        Sync: List all sessions in the specified room. Agent can query all Computers and Agents in the room via this event.

        Args:
            sid (str): 发起者ID，一般是Agent / Initiator ID, usually Agent
            data (ListRoomReq): 列出房间请求数据，包含office_id和req_id / List room request data with office_id and req_id

        Returns:
            ListRoomRet: 房间内所有会话信息列表 / List of all session info in the room
        """
        # 验证请求数据 / Validate request data
        list_room_req = TypeAdapter(ListRoomReq).validate_python(data)
        office_id = list_room_req["office_id"]
        req_id = list_room_req["req_id"]

        # 验证发起者权限：确保Agent在请求的房间内 / Verify initiator permission: ensure Agent is in the requested room
        agent_session = self.get_session(sid)
        if agent_session is None:
            # 发起者飞行中断连防御（同 _relay_client_call）：显式 raise 替代 None.get 的 AttributeError。
            raise SMCPNamespaceError("发起者会话不存在（可能已断连）：server:list_room / originator session gone")
        agent_office_id = agent_session.get("office_id")

        if agent_office_id != office_id:
            raise SMCPNamespaceError(f"Agent只能查询自己所在房间的会话信息。Agent office: {agent_office_id}, requested: {office_id}")

        # 使用工具函数获取房间内所有会话信息 / Use utility function to get all session info in the room
        all_sessions = get_all_sessions_in_office(office_id, self.server)

        # 转换为SessionInfo格式 / Convert to SessionInfo format
        sessions: list[SessionInfo] = []
        for session in all_sessions:
            if session.get("role") in ["computer", "agent"]:
                session_info: SessionInfo = {
                    "sid": session.get("sid", ""),
                    "name": session.get("name", ""),
                    "role": session["role"],
                    "office_id": session.get("office_id", ""),
                }
                # a2c_version 为 NotRequired：仅在握手时记录到则带出 / NotRequired: include only if recorded at handshake
                if session.get("a2c_version"):
                    session_info["a2c_version"] = session["a2c_version"]
                sessions.append(session_info)

        return ListRoomRet(sessions=sessions, req_id=req_id)
