Преглед изворни кода

Add host Agent and required reply handling

samien пре 2 месеци
родитељ
комит
c0e09deb9f

+ 41 - 1
hermes-plugins/yaoyao/dashboard/group_orchestrator.py

@@ -57,6 +57,9 @@ _CLAIM_RETRY_MAX_SECONDS = 1.0
 _WORK_DISABLED_RETRY_SECONDS = 1.0
 _NO_REPLY_TOKEN = "[[YAOYAO_NO_REPLY_V1]]"
 _NO_REPLY_RESERVED_PATTERN = re.compile(r"\[\[YAOYAO_[A-Z0-9_]*(?:\]\])?")
+_HOST_FALLBACK_REPLY = (
+    "我还不能确定你希望我处理什么,请补充具体目标、范围,或明确需要我协调的 Agent。"
+)
 
 
 def _is_no_reply_content(value: object) -> bool:
@@ -233,7 +236,9 @@ class _RuntimeState:
     through_seq: int
     profile: str = ""
     reply_mode: str = "mentioned"
+    required_reply: bool = False
     automatic_published: bool = False
+    had_visible_interaction: bool = False
     prompt_ready: asyncio.Event = field(default_factory=asyncio.Event)
     finished: asyncio.Event = field(default_factory=asyncio.Event)
     terminal_info_received: asyncio.Event = field(default_factory=asyncio.Event)
@@ -302,6 +307,12 @@ def _required_nonnegative_int(value: object, field_name: str) -> int:
     return value
 
 
+def _required_bool(value: object, field_name: str) -> bool:
+    if not isinstance(value, bool):
+        raise GroupOrchestratorError(f"{field_name} must be a boolean")
+    return value
+
+
 def _strict_json_copy(value: object, field_name: str) -> object:
     try:
         encoded = json.dumps(
@@ -364,6 +375,9 @@ def build_run_prompt(
             "replyMode": _required_string(
                 run.get("replyMode", "mentioned"), "projection.run.replyMode"
             ),
+            "requiredReply": _required_bool(
+                run.get("requiredReply", False), "projection.run.requiredReply"
+            ),
         },
         "throughSeq": _required_nonnegative_int(
             projection.get("throughSeq"), "projection.throughSeq"
@@ -392,7 +406,13 @@ def build_run_prompt(
         allow_nan=False,
     )
     automatic_rule = ""
-    if envelope["run"]["replyMode"] == "automatic":
+    if envelope["run"]["requiredReply"]:
+        automatic_rule = (
+            "你是本房间唯一主持人。该用户消息没有有效 @,你必须公开处理;"
+            "禁止保持静默,禁止输出任何 YAOYAO_NO_REPLY 标记。必须三选一:"
+            "直接回答;使用成员列表中的精确 @显示名 转派;或发起澄清请求。"
+        )
+    elif envelope["run"]["replyMode"] == "automatic":
         automatic_rule = (
             f"若触发内容与自己的职责无关,禁止调用工具、禁止@成员,且完整答复只能是"
             f"{_NO_REPLY_TOKEN};正常答复绝不能包含该标记。"
@@ -2011,6 +2031,13 @@ class GroupOrchestrator:
         reply_mode = _required_string(run.get("replyMode"), "projection.run.replyMode")
         if reply_mode not in {"mentioned", "automatic"}:
             raise GroupOrchestratorError("projection.run.replyMode is invalid")
+        required_reply = _required_bool(
+            run.get("requiredReply", False), "projection.run.requiredReply"
+        )
+        if required_reply and reply_mode != "automatic":
+            raise GroupOrchestratorError(
+                "projection.run.requiredReply requires automatic replyMode"
+            )
         display_name = _required_string(
             agent.get("displayName"), "projection.agent.displayName"
         )
@@ -2144,6 +2171,7 @@ class GroupOrchestrator:
                     through_seq=through_seq,
                     profile=profile,
                     reply_mode=reply_mode,
+                    required_reply=required_reply,
                 )
                 if self._closing:
                     state.prompt_ready.set()
@@ -2334,6 +2362,7 @@ class GroupOrchestrator:
         through_seq: int,
         profile: str,
         reply_mode: str,
+        required_reply: bool,
     ) -> _RuntimeState:
         with self._mapping_lock:
             if identity.runtime_id in self._runtime_states:
@@ -2353,6 +2382,8 @@ class GroupOrchestrator:
                 through_seq=through_seq,
                 profile=profile,
                 reply_mode=reply_mode,
+                required_reply=required_reply,
+                automatic_published=required_reply,
             )
             self._runtime_states[identity.runtime_id] = (
                 state.generation,
@@ -2623,6 +2654,13 @@ class GroupOrchestrator:
             if state.reply_mode == "automatic":
                 state.content = _sanitize_no_reply_text(state.content)
                 state.reasoning = _sanitize_no_reply_text(state.reasoning)
+            if (
+                state.required_reply
+                and outcome == "completed"
+                and not state.content.strip()
+                and not state.had_visible_interaction
+            ):
+                state.content = _HOST_FALLBACK_REPLY
             suppress_automatic = automatic_unpublished and (
                 outcome != "completed" or not state.content.strip()
             )
@@ -3193,6 +3231,7 @@ class GroupOrchestrator:
         if not isinstance(interaction, Mapping):
             raise GroupOrchestratorError("Stored approval is invalid")
         local_id = _required_string(interaction.get("id"), "interaction.id")
+        state.had_visible_interaction = True
         state.pending_interaction_ids.add(local_id)
 
     async def _persist_clarification(
@@ -3214,6 +3253,7 @@ class GroupOrchestrator:
         if not isinstance(interaction, Mapping):
             raise GroupOrchestratorError("Stored clarification is invalid")
         local_id = _required_string(interaction.get("id"), "interaction.id")
+        state.had_visible_interaction = True
         state.clarification_ids[request_id] = local_id
         state.pending_interaction_ids.add(local_id)
 

+ 4 - 0
hermes-plugins/yaoyao/dashboard/group_plugin_api.py

@@ -879,6 +879,8 @@ def _create_room_command(request: CreateRoomRequest) -> dict[str, object]:
                 and "reply_without_mention" not in seed.model_fields_set
             ):
                 item.pop("replyWithoutMention", None)
+            if isinstance(item, dict) and "is_host" not in seed.model_fields_set:
+                item.pop("isHost", None)
             if isinstance(item, dict):
                 for field, alias in (
                     ("model", "model"),
@@ -895,6 +897,8 @@ def _add_agent_command(request: AddAgentRequest) -> dict[str, object]:
     command = _command(request)
     if "reply_without_mention" not in request.model_fields_set:
         command.pop("replyWithoutMention", None)
+    if "is_host" not in request.model_fields_set:
+        command.pop("isHost", None)
     for field, alias in (
         ("model", "model"),
         ("provider", "provider"),

+ 13 - 2
hermes-plugins/yaoyao/dashboard/group_protocol.py

@@ -18,7 +18,7 @@ from pydantic import (
 )
 
 
-PROTOCOL_VERSION = 4
+PROTOCOL_VERSION = 5
 MAX_AGENTS_PER_ROOM = 8
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
@@ -97,6 +97,7 @@ class AgentSeed(GroupModel):
     reply_without_mention: StrictBool = Field(
         default=False, alias="replyWithoutMention"
     )
+    is_host: StrictBool = Field(default=False, alias="isHost")
     model: str | None = Field(default=None, max_length=4096)
     provider: str | None = Field(default=None, max_length=4096)
     reasoning_effort: str | None = Field(
@@ -139,6 +140,12 @@ class CreateRoomRequest(GroupModel):
     def validate_max_reply_rounds(cls, value: int) -> int:
         return normalize_max_reply_rounds(value)
 
+    @model_validator(mode="after")
+    def validate_unique_host(self) -> "CreateRoomRequest":
+        if sum(agent.is_host for agent in self.agents) > 1:
+            raise ValueError("Room may contain only one host Agent")
+        return self
+
 
 class UpdateRoomRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
@@ -175,6 +182,7 @@ class AddAgentRequest(GroupModel):
     reply_without_mention: StrictBool = Field(
         default=False, alias="replyWithoutMention"
     )
+    is_host: StrictBool = Field(default=False, alias="isHost")
     model: str | None = Field(default=None, max_length=4096)
     provider: str | None = Field(default=None, max_length=4096)
     reasoning_effort: str | None = Field(
@@ -213,6 +221,7 @@ class UpdateAgentRequest(GroupModel):
     reply_without_mention: StrictBool | None = Field(
         default=None, alias="replyWithoutMention"
     )
+    is_host: StrictBool | None = Field(default=None, alias="isHost")
     model: str | None = Field(default=None, max_length=4096)
     provider: str | None = Field(default=None, max_length=4096)
     reasoning_effort: str | None = Field(
@@ -221,7 +230,8 @@ class UpdateAgentRequest(GroupModel):
     fast_mode: StrictBool | None = Field(default=None, alias="fastMode")
 
     @field_validator(
-        "display_name", "description", "enabled", "reply_without_mention", mode="before"
+        "display_name", "description", "enabled", "reply_without_mention", "is_host",
+        mode="before"
     )
     @classmethod
     def reject_explicit_null(cls, value: Any) -> Any:
@@ -249,6 +259,7 @@ class UpdateAgentRequest(GroupModel):
         if not (
             {
                 "display_name", "description", "enabled", "reply_without_mention",
+                "is_host",
                 "model", "provider", "reasoning_effort", "fast_mode",
             }
             & self.model_fields_set

+ 374 - 42
hermes-plugins/yaoyao/dashboard/group_store.py

@@ -41,7 +41,7 @@ from .group_protocol import (
 )
 
 
-SCHEMA_VERSION = 4
+SCHEMA_VERSION = 5
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
@@ -93,7 +93,7 @@ _CASCADE_OPERATIONS = frozenset(
         _CASCADE_DISCARDED_OPERATION,
     }
 )
-_CASCADE_PARSE_VERSION = 2
+_CASCADE_PARSE_VERSION = 3
 _MAX_CASCADE_PAGE_SIZE = 32
 _MAX_CASCADE_PLAN_BYTES = 16 * 1024
 _TOPIC_TITLE_LENGTH = 120
@@ -135,6 +135,7 @@ _SCHEMA_STATEMENTS = (
         last_context_message_seq INTEGER NOT NULL DEFAULT 0,
         enabled INTEGER NOT NULL DEFAULT 1,
         reply_without_mention INTEGER NOT NULL DEFAULT 0,
+        is_host INTEGER NOT NULL DEFAULT 0,
         model_override TEXT,
         provider_override TEXT,
         reasoning_effort_override TEXT,
@@ -185,6 +186,7 @@ _SCHEMA_STATEMENTS = (
         root_message_id TEXT NOT NULL,
         depth INTEGER NOT NULL,
         reply_mode TEXT NOT NULL DEFAULT 'mentioned',
+        required_reply INTEGER NOT NULL DEFAULT 0,
         status TEXT NOT NULL,
         runtime_session_id TEXT,
         error TEXT NOT NULL DEFAULT '',
@@ -231,6 +233,8 @@ _SCHEMA_STATEMENTS = (
     "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_agent_status ON group_agent_runs(agent_id, status)",
     "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_room_status ON group_agent_runs(room_id, status)",
     "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_status_order ON group_agent_runs(status, created_at, id)",
+    """CREATE UNIQUE INDEX IF NOT EXISTS idx_group_agents_room_host
+    ON group_agents(room_id) WHERE is_host = 1""",
     """CREATE UNIQUE INDEX IF NOT EXISTS idx_group_agent_runs_active_runtime
     ON group_agent_runs(runtime_session_id)
     WHERE runtime_session_id IS NOT NULL
@@ -453,16 +457,18 @@ class GroupStore:
             raw_version = "3"
         if raw_version == "3":
             self._migrate_v3_to_v4(connection)
-        elif raw_version == "4":
+            raw_version = "4"
+        if raw_version == "4":
             self._repair_early_v4(connection)
+            self._migrate_v4_to_v5(connection)
         self._validated_schema_version(
             self._metadata_value_from_connection(connection, "schema_version")
         )
         self._validated_epoch(
             self._metadata_value_from_connection(connection, "journal_epoch")
         )
-        self._validate_v4_columns(connection)
-        self._validate_v4_values(connection)
+        self._validate_v5_columns(connection)
+        self._validate_v5_values(connection)
 
     @staticmethod
     def _migrate_v1_to_v2(connection: sqlite3.Connection) -> None:
@@ -993,16 +999,98 @@ class GroupStore:
             )"""
         )
 
+    @classmethod
+    def _migrate_v4_to_v5(cls, connection: sqlite3.Connection) -> None:
+        """Assign one durable host per room and freeze required-reply run state."""
+        connection.execute(
+            "ALTER TABLE group_agents ADD COLUMN is_host INTEGER NOT NULL DEFAULT 0"
+        )
+        connection.execute(
+            """ALTER TABLE group_agent_runs
+            ADD COLUMN required_reply INTEGER NOT NULL DEFAULT 0"""
+        )
+        for room in connection.execute(
+            "SELECT id FROM group_rooms ORDER BY created_at ASC, id ASC"
+        ).fetchall():
+            host = connection.execute(
+                """SELECT id FROM group_agents WHERE room_id = ?
+                ORDER BY CASE
+                    WHEN enabled = 1 AND reply_without_mention = 1 THEN 0
+                    WHEN enabled = 1 THEN 1
+                    ELSE 2
+                END, created_at ASC, id ASC
+                LIMIT 1""",
+                (room["id"],),
+            ).fetchone()
+            if host is None:
+                raise GroupStoreError("GroupStore room has no host candidate")
+            connection.execute(
+                "UPDATE group_agents SET is_host = 1 WHERE id = ? AND room_id = ?",
+                (host["id"], room["id"]),
+            )
+
+        def add_host_flags(value: object) -> object:
+            if isinstance(value, list):
+                return [add_host_flags(item) for item in value]
+            if not isinstance(value, dict):
+                return value
+            rewritten = {key: add_host_flags(item) for key, item in value.items()}
+            identity = rewritten.get("id")
+            room_id = rewritten.get("roomId")
+            if (
+                isinstance(identity, str)
+                and isinstance(room_id, str)
+                and {"profile", "displayName", "replyWithoutMention"}
+                <= rewritten.keys()
+            ):
+                agent = connection.execute(
+                    "SELECT is_host FROM group_agents WHERE id = ? AND room_id = ?",
+                    (identity, room_id),
+                ).fetchone()
+                rewritten["isHost"] = bool(agent["is_host"]) if agent else False
+            return rewritten
+
+        for ledger in connection.execute(
+            "SELECT request_id, response_json FROM group_idempotency"
+        ).fetchall():
+            try:
+                response = cls._load_json(ledger["response_json"])
+            except (TypeError, ValueError, json.JSONDecodeError) as error:
+                raise GroupStoreError("Stored idempotency response is corrupt") from error
+            rewritten = cls._canonical_json(add_host_flags(response))
+            if rewritten != ledger["response_json"]:
+                connection.execute(
+                    """UPDATE group_idempotency SET response_json = ?
+                    WHERE request_id = ?""",
+                    (rewritten, ledger["request_id"]),
+                )
+        for event in connection.execute(
+            "SELECT cursor, payload_json FROM group_events"
+        ).fetchall():
+            try:
+                payload = cls._load_json(event["payload_json"])
+            except (TypeError, ValueError, json.JSONDecodeError) as error:
+                raise GroupStoreError("Stored event is corrupt") from error
+            rewritten = cls._canonical_json(add_host_flags(payload))
+            if rewritten != event["payload_json"]:
+                connection.execute(
+                    "UPDATE group_events SET payload_json = ? WHERE cursor = ?",
+                    (rewritten, event["cursor"]),
+                )
+        connection.execute(
+            "UPDATE group_meta SET value = '5' WHERE key = 'schema_version'"
+        )
+
     @staticmethod
-    def _validate_v4_columns(connection: sqlite3.Connection) -> None:
+    def _validate_v5_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
             "group_meta": {"key", "value"},
             "group_rooms": {"id", "name", "cwd", "max_reply_rounds", "created_at", "updated_at", "archived"},
             "group_topics": {"id", "room_id", "title", "created_at", "updated_at"},
-            "group_agents": {"id", "room_id", "profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
+            "group_agents": {"id", "room_id", "profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "is_host", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
             "group_agent_topic_state": {"agent_id", "topic_id", "last_context_message_seq", "created_at", "updated_at"},
             "group_messages": {"seq", "id", "room_id", "topic_id", "sender_kind", "sender_id", "sender_name", "root_message_id", "reply_to_message_id", "client_message_id", "content", "reasoning", "tool_state_json", "status", "error", "visible", "created_at", "updated_at"},
-            "group_agent_runs": {"id", "room_id", "topic_id", "agent_id", "trigger_message_id", "response_message_id", "root_message_id", "depth", "reply_mode", "status", "runtime_session_id", "error", "created_at", "updated_at"},
+            "group_agent_runs": {"id", "room_id", "topic_id", "agent_id", "trigger_message_id", "response_message_id", "root_message_id", "depth", "reply_mode", "required_reply", "status", "runtime_session_id", "error", "created_at", "updated_at"},
             "group_interactions": {"id", "room_id", "topic_id", "agent_id", "run_id", "kind", "payload_json", "status", "created_at", "resolved_at"},
             "group_events": {"cursor", "epoch", "room_id", "event_type", "payload_json", "created_at"},
             "group_idempotency": {"request_id", "operation", "request_hash", "response_json", "created_at"},
@@ -1010,8 +1098,10 @@ class GroupStore:
         exact_specs = {
             ("group_rooms", "max_reply_rounds"): ("INTEGER", 1, "3"),
             ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
+            ("group_agents", "is_host"): ("INTEGER", 1, "0"),
             ("group_messages", "visible"): ("INTEGER", 1, "1"),
             ("group_agent_runs", "reply_mode"): ("TEXT", 1, "'mentioned'"),
+            ("group_agent_runs", "required_reply"): ("INTEGER", 1, "0"),
             ("group_messages", "topic_id"): ("TEXT", 1, None),
             ("group_agent_runs", "topic_id"): ("TEXT", 1, None),
             ("group_interactions", "topic_id"): ("TEXT", 1, None),
@@ -1034,7 +1124,7 @@ class GroupStore:
                 raise GroupStoreError("GroupStore schema is partial or corrupt")
 
     @staticmethod
-    def _validate_v4_values(connection: sqlite3.Connection) -> None:
+    def _validate_v5_values(connection: sqlite3.Connection) -> None:
         invalid_value_queries = (
             f"""SELECT 1 FROM group_rooms
                 WHERE typeof(max_reply_rounds) != 'integer'
@@ -1046,6 +1136,22 @@ class GroupStore:
                 WHERE typeof(reply_without_mention) != 'integer'
                    OR reply_without_mention NOT IN (0, 1)
                 LIMIT 1""",
+            """SELECT 1 FROM group_agents
+                WHERE typeof(is_host) != 'integer' OR is_host NOT IN (0, 1)
+                LIMIT 1""",
+            """SELECT 1 FROM group_rooms AS room
+                LEFT JOIN group_agents AS agent ON agent.room_id = room.id
+                GROUP BY room.id
+                HAVING SUM(CASE WHEN agent.is_host = 1 THEN 1 ELSE 0 END) != 1
+                LIMIT 1""",
+            """SELECT 1 FROM group_agents AS host
+                WHERE host.is_host = 1 AND host.enabled = 0
+                  AND EXISTS (
+                    SELECT 1 FROM group_agents AS candidate
+                    WHERE candidate.room_id = host.room_id
+                      AND candidate.enabled = 1 AND candidate.id != host.id
+                  )
+                LIMIT 1""",
             """SELECT 1 FROM group_agents
                 WHERE fast_mode_override IS NOT NULL
                   AND (typeof(fast_mode_override) != 'integer'
@@ -1064,6 +1170,16 @@ class GroupStore:
                 WHERE typeof(reply_mode) != 'text'
                    OR reply_mode NOT IN ('mentioned', 'automatic')
                 LIMIT 1""",
+            """SELECT 1 FROM group_agent_runs
+                WHERE typeof(required_reply) != 'integer'
+                   OR required_reply NOT IN (0, 1)
+                   OR (required_reply = 1 AND reply_mode != 'automatic')
+                LIMIT 1""",
+            """SELECT 1 FROM group_agent_runs AS run
+                JOIN group_messages AS response
+                  ON response.id = run.response_message_id
+                WHERE run.required_reply = 1 AND response.visible != 1
+                LIMIT 1""",
             """SELECT 1 FROM group_agent_topic_state
                 WHERE typeof(last_context_message_seq) != 'integer'
                    OR last_context_message_seq < 0
@@ -1196,6 +1312,25 @@ class GroupStore:
                 continue
             connection.execute(f"DROP INDEX IF EXISTS {name}")
             connection.execute(f"CREATE INDEX {name} ON {definition}")
+        host_definition = """CREATE UNIQUE INDEX idx_group_agents_room_host
+            ON group_agents(room_id) WHERE is_host = 1"""
+        stored = connection.execute(
+            """SELECT sql FROM sqlite_master
+            WHERE type = 'index' AND name = ?""",
+            ("idx_group_agents_room_host",),
+        ).fetchone()
+        expected_sql = canonical_index_sql(host_definition)
+        stored_sql = (
+            None
+            if stored is None or stored["sql"] is None
+            else canonical_index_sql(stored["sql"])
+        )
+        if stored_sql != expected_sql:
+            connection.execute("DROP INDEX IF EXISTS idx_group_agents_room_host")
+            try:
+                connection.execute(host_definition)
+            except sqlite3.IntegrityError as error:
+                raise GroupStoreError("Room hosts are not unique") from error
         active_runtime_definition = """CREATE UNIQUE INDEX
             idx_group_agent_runs_active_runtime
             ON group_agent_runs(runtime_session_id)
@@ -1449,7 +1584,8 @@ class GroupStore:
             raise ValueError("limit must be an integer from 1 to 100")
         cursor_values = self._decode_cursor(cursor) if cursor is not None else None
         query = """SELECT id, name, cwd, max_reply_rounds, created_at, updated_at, archived,
-            (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id) AS agent_count
+            (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id) AS agent_count,
+            (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id) AS topic_count
             FROM group_rooms WHERE archived = 0"""
         params: list[object] = []
         if cursor_values is not None:
@@ -1614,6 +1750,7 @@ class GroupStore:
                 "displayName",
                 "description",
                 "replyWithoutMention",
+                "isHost",
                 "model",
                 "provider",
                 "reasoningEffort",
@@ -1640,6 +1777,7 @@ class GroupStore:
                         "displayName",
                         "description",
                         "replyWithoutMention",
+                        "isHost",
                         "model",
                         "provider",
                         "reasoningEffort",
@@ -1649,13 +1787,35 @@ class GroupStore:
                 }
             )
             now = self._now()
+            requested_host = bool(agent["is_host"])
+            agent["is_host"] = False
             agent_id = self._insert_agent(connection, canonical_room_id, agent, now)
+            current_host = self._room_host(connection, canonical_room_id)
+            switched: tuple[dict[str, object], dict[str, object]] | None = None
+            if requested_host or not bool(current_host["enabled"]):
+                switched = self._switch_host(
+                    connection, canonical_room_id, agent_id, now=now
+                )
             result = self._agent_detail(connection, canonical_room_id, agent_id)
             connection.execute(
                 "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
                 (now, canonical_room_id),
             )
-            self._append_event(connection, canonical_room_id, "agent.created", result)
+            if switched is not None:
+                self._append_event(
+                    connection,
+                    canonical_room_id,
+                    "agent.updated",
+                    switched[0],
+                    created_at=now,
+                )
+            self._append_event(
+                connection,
+                canonical_room_id,
+                "agent.created",
+                result,
+                created_at=now,
+            )
             self._append_room_updated_summary(
                 connection, canonical_room_id, created_at=now
             )
@@ -1688,6 +1848,7 @@ class GroupStore:
                 "description",
                 "enabled",
                 "replyWithoutMention",
+                "isHost",
                 "model",
                 "provider",
                 "reasoningEffort",
@@ -1702,6 +1863,7 @@ class GroupStore:
                 "description",
                 "enabled",
                 "replyWithoutMention",
+                "isHost",
                 "model",
                 "provider",
                 "reasoningEffort",
@@ -1726,6 +1888,31 @@ class GroupStore:
             previous = self._owned_agent(
                 connection, canonical_room_id, canonical_agent_id
             )
+            requested_host = command.get("isHost")
+            if requested_host is not None and not isinstance(requested_host, bool):
+                raise ValueError("isHost must be a boolean")
+            if requested_host is False and bool(previous["is_host"]):
+                raise GroupConflictError(
+                    "Current host cannot be cleared without selecting a replacement"
+                )
+            if requested_host is True and command.get("enabled") is False:
+                raise GroupConflictError("Host Agent must be enabled")
+            replacement_id: str | None = None
+            if (
+                bool(previous["is_host"])
+                and command.get("enabled") is False
+                and bool(previous["enabled"])
+            ):
+                replacement = self._first_enabled_host_candidate(
+                    connection,
+                    canonical_room_id,
+                    exclude_agent_id=canonical_agent_id,
+                )
+                if replacement is None:
+                    raise GroupConflictError(
+                        "Host Agent cannot be disabled without an enabled replacement"
+                    )
+                replacement_id = str(replacement["id"])
             assignments: list[str] = []
             values: list[object] = []
             if "displayName" in changes:
@@ -1799,6 +1986,21 @@ class GroupStore:
                 f"UPDATE group_agents SET {', '.join(assignments)} WHERE id = ? AND room_id = ?",
                 values,
             )
+            switched: tuple[dict[str, object], dict[str, object]] | None = None
+            if replacement_id is not None:
+                switched = self._switch_host(
+                    connection, canonical_room_id, replacement_id, now=now
+                )
+            elif requested_host is True:
+                switched = self._switch_host(
+                    connection, canonical_room_id, canonical_agent_id, now=now
+                )
+            elif command.get("enabled") is True:
+                current_host = self._room_host(connection, canonical_room_id)
+                if not bool(current_host["enabled"]):
+                    switched = self._switch_host(
+                        connection, canonical_room_id, canonical_agent_id, now=now
+                    )
             connection.execute(
                 "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
                 (now, canonical_room_id),
@@ -1806,7 +2008,29 @@ class GroupStore:
             result = self._agent_detail(
                 connection, canonical_room_id, canonical_agent_id
             )
-            self._append_event(connection, canonical_room_id, "agent.updated", result)
+            if switched is None:
+                self._append_event(
+                    connection,
+                    canonical_room_id,
+                    "agent.updated",
+                    result,
+                    created_at=now,
+                )
+            else:
+                self._append_event(
+                    connection,
+                    canonical_room_id,
+                    "agent.updated",
+                    switched[0],
+                    created_at=now,
+                )
+                self._append_event(
+                    connection,
+                    canonical_room_id,
+                    "agent.updated",
+                    switched[1],
+                    created_at=now,
+                )
             self._append_room_updated_summary(
                 connection, canonical_room_id, created_at=now
             )
@@ -1854,6 +2078,23 @@ class GroupStore:
             if agent_count <= 1:
                 raise GroupConflictError("Room must retain at least one agent")
             now = self._now()
+            switched: tuple[dict[str, object], dict[str, object]] | None = None
+            if result["isHost"] is True:
+                replacement = self._first_enabled_host_candidate(
+                    connection,
+                    canonical_room_id,
+                    exclude_agent_id=canonical_agent_id,
+                )
+                if replacement is None:
+                    raise GroupConflictError(
+                        "Host Agent cannot be removed without an enabled replacement"
+                    )
+                switched = self._switch_host(
+                    connection,
+                    canonical_room_id,
+                    str(replacement["id"]),
+                    now=now,
+                )
             interrupted = self._interrupt_runs_for_scope(
                 connection,
                 room_id=canonical_room_id,
@@ -1869,6 +2110,14 @@ class GroupStore:
                 "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
                 (now, canonical_room_id),
             )
+            if switched is not None:
+                self._append_event(
+                    connection,
+                    canonical_room_id,
+                    "agent.updated",
+                    switched[1],
+                    created_at=now,
+                )
             self._append_event(
                 connection, canonical_room_id, "agent.deleted", result, created_at=now
             )
@@ -1963,7 +2212,7 @@ class GroupStore:
 
         def action(connection: sqlite3.Connection) -> dict[str, object]:
             self._active_room(connection, canonical_room_id)
-            agents: list[tuple[sqlite3.Row, str]] = []
+            agents: list[tuple[sqlite3.Row, str, bool]] = []
             room_agents = connection.execute(
                 """SELECT * FROM group_agents WHERE room_id = ?
                 ORDER BY created_at ASC, id ASC""",
@@ -1975,23 +2224,30 @@ class GroupStore:
                 source_agent_id="human",
                 include_automatic=False,
             )
-            explicit_agent_ids = list(dict.fromkeys([*agent_ids, *parsed_ids]))
-            explicit_ids = set(explicit_agent_ids)
-            for agent_id in explicit_agent_ids:
-                agent = self._owned_agent(connection, canonical_room_id, agent_id)
-                if not agent["enabled"]:
-                    raise GroupConflictError("Mentioned agent is disabled")
-                agents.append((agent, "mentioned"))
-            automatic_agents = [
-                agent
-                for agent in room_agents
-                if agent["enabled"] and agent["reply_without_mention"]
+            agents_by_id = {str(agent["id"]): agent for agent in room_agents}
+            explicit_agent_ids = [
+                agent_id
+                for agent_id in dict.fromkeys([*agent_ids, *parsed_ids])
+                if agent_id in agents_by_id and agents_by_id[agent_id]["enabled"]
             ]
-            agents.extend(
-                (agent, "automatic")
-                for agent in automatic_agents
-                if agent["id"] not in explicit_ids
-            )
+            if explicit_agent_ids:
+                agents.extend(
+                    (agents_by_id[agent_id], "mentioned", False)
+                    for agent_id in explicit_agent_ids
+                )
+            else:
+                host = self._room_host(connection, canonical_room_id)
+                scheduled_ids: set[str] = set()
+                if host["enabled"]:
+                    agents.append((host, "automatic", True))
+                    scheduled_ids.add(str(host["id"]))
+                agents.extend(
+                    (agent, "automatic", False)
+                    for agent in room_agents
+                    if agent["enabled"]
+                    and agent["reply_without_mention"]
+                    and agent["id"] not in scheduled_ids
+                )
             now = self._now()
             self._ensure_topic(
                 connection,
@@ -2023,8 +2279,8 @@ class GroupStore:
                 connection, canonical_room_id, "message.upsert", message, created_at=now
             )
             runs: list[dict[str, object]] = []
-            for agent, reply_mode in agents:
-                visible = reply_mode == "mentioned"
+            for agent, reply_mode, required_reply in agents:
+                visible = reply_mode == "mentioned" or required_reply
                 response_id, run_id = str(uuid.uuid4()), str(uuid.uuid4())
                 connection.execute(
                     """INSERT INTO group_messages
@@ -2048,8 +2304,9 @@ class GroupStore:
                 connection.execute(
                     """INSERT INTO group_agent_runs
                     (id, room_id, topic_id, agent_id, trigger_message_id, response_message_id, root_message_id,
-                     depth, reply_mode, status, runtime_session_id, error, created_at, updated_at)
-                    VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 'queued', NULL, '', ?, ?)""",
+                     depth, reply_mode, required_reply, status, runtime_session_id,
+                     error, created_at, updated_at)
+                    VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 'queued', NULL, '', ?, ?)""",
                     (
                         run_id,
                         canonical_room_id,
@@ -2059,6 +2316,7 @@ class GroupStore:
                         response_id,
                         human_id,
                         reply_mode,
+                        int(required_reply),
                         now,
                         now,
                     ),
@@ -2288,8 +2546,10 @@ class GroupStore:
                     omitted_through_seq=omitted_through_seq,
                 )
             )
+            projected_run = self._run_wire(run)
+            projected_run["requiredReply"] = bool(run["required_reply"])
             return {
-                "run": self._run_wire(run),
+                "run": projected_run,
                 "room": self._room_wire(room),
                 "agent": projected_agent,
                 "messages": messages,
@@ -2822,7 +3082,7 @@ class GroupStore:
                 index += 1 + len(token.group(0))
             else:
                 index += 1
-        if include_automatic:
+        if include_automatic and not targets:
             for agent in agents:
                 if (
                     agent["enabled"]
@@ -2985,7 +3245,7 @@ class GroupStore:
             raise GroupStoreError("Stored cascade plan is corrupt")
         parse_version = plan.get("parseVersion")
         if (
-            parse_version not in {1, 2}
+            parse_version not in {1, 2, 3}
             or request_hash
             != self._cascade_request_hash(source_id, parse_version=parse_version)
         ):
@@ -3160,6 +3420,7 @@ class GroupStore:
         expected_hashes = {
             self._cascade_request_hash(canonical_source_id, parse_version=1),
             self._cascade_request_hash(canonical_source_id, parse_version=2),
+            self._cascade_request_hash(canonical_source_id, parse_version=3),
         }
         with self.write_transaction() as connection:
             ledger = connection.execute(
@@ -3406,9 +3667,10 @@ class GroupStore:
             connection.execute(
                 """INSERT INTO group_agent_runs
                 (id, room_id, topic_id, agent_id, trigger_message_id,
-                 response_message_id, root_message_id, depth, reply_mode, status,
-                 runtime_session_id, error, created_at, updated_at)
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', NULL, '', ?, ?)""",
+                 response_message_id, root_message_id, depth, reply_mode,
+                 required_reply, status, runtime_session_id, error, created_at,
+                 updated_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'queued', NULL, '', ?, ?)""",
                 (
                     run_id,
                     source["room_id"],
@@ -4707,7 +4969,9 @@ class GroupStore:
         room = connection.execute(
             """SELECT id, name, cwd, max_reply_rounds, created_at, updated_at, archived,
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id)
-                AS agent_count
+                AS agent_count,
+            (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id)
+                AS topic_count
             FROM group_rooms WHERE id = ? AND archived = 0""",
             (room_id,),
         ).fetchone()
@@ -4897,6 +5161,61 @@ class GroupStore:
             raise GroupNotFoundError("Agent not found in room")
         return agent
 
+    @staticmethod
+    def _room_host(
+        connection: sqlite3.Connection, room_id: str
+    ) -> sqlite3.Row:
+        host = connection.execute(
+            "SELECT * FROM group_agents WHERE room_id = ? AND is_host = 1",
+            (room_id,),
+        ).fetchone()
+        if host is None:
+            raise GroupStoreError("Room host is missing")
+        return host
+
+    @staticmethod
+    def _first_enabled_host_candidate(
+        connection: sqlite3.Connection,
+        room_id: str,
+        *,
+        exclude_agent_id: str,
+    ) -> sqlite3.Row | None:
+        return connection.execute(
+            """SELECT * FROM group_agents
+            WHERE room_id = ? AND enabled = 1 AND id != ?
+            ORDER BY created_at ASC, id ASC LIMIT 1""",
+            (room_id, exclude_agent_id),
+        ).fetchone()
+
+    def _switch_host(
+        self,
+        connection: sqlite3.Connection,
+        room_id: str,
+        agent_id: str,
+        *,
+        now: float,
+    ) -> tuple[dict[str, object], dict[str, object]] | None:
+        current = self._room_host(connection, room_id)
+        if current["id"] == agent_id:
+            return None
+        replacement = self._owned_agent(connection, room_id, agent_id)
+        if not replacement["enabled"]:
+            raise GroupConflictError("Host Agent must be enabled")
+        connection.execute(
+            """UPDATE group_agents SET is_host = 0, updated_at = ?
+            WHERE id = ? AND room_id = ?""",
+            (now, current["id"], room_id),
+        )
+        connection.execute(
+            """UPDATE group_agents SET is_host = 1, updated_at = ?
+            WHERE id = ? AND room_id = ?""",
+            (now, replacement["id"], room_id),
+        )
+        return (
+            self._agent_detail(connection, room_id, str(current["id"])),
+            self._agent_detail(connection, room_id, str(replacement["id"])),
+        )
+
     def _enabled_agent(
         self, connection: sqlite3.Connection, room_id: str, agent_id: str
     ) -> sqlite3.Row:
@@ -5394,6 +5713,11 @@ class GroupStore:
             raise GroupConflictError("Agent profile must be unique within a room")
         if len(display_keys) != len(set(display_keys)):
             raise GroupConflictError("Agent display name must be unique within a room")
+        host_count = sum(bool(agent["is_host"]) for agent in agents)
+        if host_count > 1:
+            raise GroupConflictError("Room may contain only one host Agent")
+        if host_count == 0:
+            agents[0]["is_host"] = True
         return agents
 
     def _new_agent(self, command: object) -> dict[str, object]:
@@ -5401,6 +5725,7 @@ class GroupStore:
             command,
             {
                 "profile", "displayName", "description", "replyWithoutMention",
+                "isHost",
                 "model", "provider", "reasoningEffort", "fastMode",
             },
         )
@@ -5417,6 +5742,9 @@ class GroupStore:
         reply_without_mention = command.get("replyWithoutMention", False)
         if not isinstance(reply_without_mention, bool):
             raise ValueError("replyWithoutMention must be a boolean")
+        is_host = command.get("isHost", False)
+        if not isinstance(is_host, bool):
+            raise ValueError("isHost must be a boolean")
         fast_mode = command.get("fastMode")
         if fast_mode is not None and not isinstance(fast_mode, bool):
             raise ValueError("fastMode must be a boolean or null")
@@ -5428,6 +5756,7 @@ class GroupStore:
             "display_name_key": display_name_key,
             "description": self._description(command.get("description", "")),
             "reply_without_mention": reply_without_mention,
+            "is_host": is_host,
             "model_override": self._agent_configuration_value(
                 command.get("model"), "model"
             ),
@@ -5477,9 +5806,9 @@ class GroupStore:
         connection.execute(
             """INSERT INTO group_agents
             (id, room_id, profile, display_name, display_name_key, description,
-             reply_without_mention, model_override, provider_override,
+             reply_without_mention, is_host, model_override, provider_override,
              reasoning_effort_override, fast_mode_override, created_at, updated_at)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
             (
                 agent_id,
                 room_id,
@@ -5488,6 +5817,7 @@ class GroupStore:
                 agent["display_name_key"],
                 agent["description"],
                 int(bool(agent["reply_without_mention"])),
+                int(bool(agent["is_host"])),
                 agent["model_override"],
                 agent["provider_override"],
                 agent["reasoning_effort_override"],
@@ -5559,6 +5889,7 @@ class GroupStore:
             "updatedAt": row["updated_at"],
             "archived": False,
             "agentCount": row["agent_count"],
+            "topicCount": row["topic_count"],
         }
 
     @staticmethod
@@ -5586,6 +5917,7 @@ class GroupStore:
             "lastContextMessageSeq": row["last_context_message_seq"],
             "enabled": bool(row["enabled"]),
             "replyWithoutMention": bool(row["reply_without_mention"]),
+            "isHost": bool(row["is_host"]),
             "model": row["model_override"],
             "provider": row["provider_override"],
             "reasoningEffort": row["reasoning_effort_override"],

+ 727 - 0
hermes-plugins/yaoyao/dashboard/tests/test_group_host_contract.py

@@ -0,0 +1,727 @@
+from __future__ import annotations
+
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+import importlib
+import json
+from pathlib import Path
+import sqlite3
+import sys
+import tempfile
+import threading
+import unittest
+import uuid
+
+
+DASHBOARD_DIR = Path(__file__).resolve().parents[1]
+if str(DASHBOARD_DIR) not in sys.path:
+    sys.path.insert(0, str(DASHBOARD_DIR))
+
+import group_plugin_api  # noqa: E402
+
+
+PROTOCOL = importlib.import_module(f"{group_plugin_api._LOCAL_PACKAGE}.group_protocol")
+STORE_MODULE = importlib.import_module(f"{group_plugin_api._LOCAL_PACKAGE}.group_store")
+ORCHESTRATOR = importlib.import_module(
+    f"{group_plugin_api._LOCAL_PACKAGE}.group_orchestrator"
+)
+GroupStore = STORE_MODULE.GroupStore
+
+
+def new_id() -> str:
+    return str(uuid.uuid4())
+
+
+class GroupHostContractTests(unittest.TestCase):
+    def test_protocol_v5_host_fields_are_independent_and_compatible(self) -> None:
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 5)
+        legacy = PROTOCOL.CreateRoomRequest.model_validate({
+            "requestId": new_id(),
+            "name": "兼容群",
+            "agents": [{"profile": "legacy", "replyWithoutMention": True}],
+        })
+        self.assertFalse(legacy.agents[0].is_host)
+        self.assertTrue(legacy.agents[0].reply_without_mention)
+        legacy_command = group_plugin_api._create_room_command(legacy)
+        self.assertNotIn("isHost", legacy_command["agents"][0])
+
+        explicit = PROTOCOL.CreateRoomRequest.model_validate({
+            "requestId": new_id(),
+            "name": "v5 群",
+            "agents": [
+                {"profile": "host", "isHost": True},
+                {"profile": "observer", "replyWithoutMention": True},
+            ],
+        })
+        explicit_command = group_plugin_api._create_room_command(explicit)
+        self.assertIs(explicit_command["agents"][0]["isHost"], True)
+        self.assertNotIn("isHost", explicit_command["agents"][1])
+        update = PROTOCOL.UpdateAgentRequest.model_validate({
+            "requestId": new_id(),
+            "isHost": True,
+        })
+        self.assertIs(update.is_host, True)
+
+        with self.assertRaisesRegex(ValueError, "only one host"):
+            PROTOCOL.CreateRoomRequest.model_validate({
+                "requestId": new_id(),
+                "name": "非法群",
+                "agents": [
+                    {"profile": "one", "isHost": True},
+                    {"profile": "two", "isHost": True},
+                ],
+            })
+
+    def test_human_routing_uses_host_only_when_no_valid_mention(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "路由群",
+                "cwd": "",
+                "agents": [
+                    {
+                        "profile": "host",
+                        "isHost": True,
+                        "replyWithoutMention": True,
+                    },
+                    {"profile": "automatic", "replyWithoutMention": True},
+                    {"profile": "explicit"},
+                    {"profile": "disabled", "replyWithoutMention": True},
+                ],
+            })
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            store.update_agent(
+                room["id"],
+                agents["disabled"]["id"],
+                {"requestId": new_id(), "enabled": False},
+            )
+
+            no_mention = self._send(store, room["id"], "普通消息", [])
+            self.assertEqual(
+                self._run_modes(no_mention),
+                {
+                    agents["host"]["id"]: "automatic",
+                    agents["automatic"]["id"]: "automatic",
+                },
+            )
+            with store.connection() as connection:
+                flags = {
+                    row["agent_id"]: row["required_reply"]
+                    for row in connection.execute(
+                        """SELECT agent_id, required_reply FROM group_agent_runs
+                        WHERE id IN (?, ?)""",
+                        tuple(run["id"] for run in no_mention["runs"]),
+                    )
+                }
+                visibility = {
+                    row["sender_id"]: row["visible"]
+                    for row in connection.execute(
+                        """SELECT sender_id, visible FROM group_messages
+                        WHERE id IN (?, ?)""",
+                        tuple(run["responseMessageId"] for run in no_mention["runs"]),
+                    )
+                }
+            self.assertEqual(
+                flags,
+                {agents["host"]["id"]: 1, agents["automatic"]["id"]: 0},
+            )
+            self.assertEqual(
+                visibility,
+                {agents["host"]["id"]: 1, agents["automatic"]["id"]: 0},
+            )
+
+            explicit = self._send(
+                store, room["id"], "请处理", [agents["explicit"]["id"]]
+            )
+            self.assertEqual(
+                self._run_modes(explicit),
+                {agents["explicit"]["id"]: "mentioned"},
+            )
+            merged = self._send(
+                store,
+                room["id"],
+                "@automatic 一起处理",
+                [agents["explicit"]["id"]],
+            )
+            self.assertEqual(
+                self._run_modes(merged),
+                {
+                    agents["explicit"]["id"]: "mentioned",
+                    agents["automatic"]["id"]: "mentioned",
+                },
+            )
+
+            for content, ids in (
+                ("@不存在 请处理", []),
+                ("失效请求目标", [new_id()]),
+                ("@disabled 请处理", []),
+            ):
+                with self.subTest(content=content):
+                    fallback = self._send(store, room["id"], content, ids)
+                    self.assertEqual(
+                        self._run_modes(fallback),
+                        {
+                            agents["host"]["id"]: "automatic",
+                            agents["automatic"]["id"]: "automatic",
+                        },
+                    )
+
+            everyone = self._send(store, room["id"], "@all 请处理", [])
+            self.assertEqual(
+                set(self._run_modes(everyone)),
+                {
+                    agents["host"]["id"],
+                    agents["automatic"]["id"],
+                    agents["explicit"]["id"],
+                },
+            )
+            self.assertTrue(
+                all(mode == "mentioned" for mode in self._run_modes(everyone).values())
+            )
+
+    def test_agent_cascade_explicit_mentions_exclude_automatic_members(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "级联群",
+                "cwd": "",
+                "agents": [
+                    {"profile": "source", "isHost": True},
+                    {"profile": "automatic", "replyWithoutMention": True},
+                    {"profile": "target"},
+                ],
+            })
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            explicit_source = self._insert_completed_source(
+                store,
+                room["id"],
+                agents["source"]["id"],
+                content="@target 继续",
+            )
+            explicit = store.complete_cascade(explicit_source)
+            self.assertEqual(
+                {store.get_run(run_id)["agentId"] for run_id in explicit["runIds"]},
+                {agents["target"]["id"]},
+            )
+
+            automatic_source = self._insert_completed_source(
+                store,
+                room["id"],
+                agents["source"]["id"],
+                content="没有明确目标",
+            )
+            automatic = store.complete_cascade(automatic_source)
+            self.assertEqual(
+                {store.get_run(run_id)["agentId"] for run_id in automatic["runIds"]},
+                {agents["automatic"]["id"]},
+            )
+
+    def test_eight_member_no_mention_routing_deduplicates_the_host(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "八成员群",
+                "cwd": "",
+                "agents": [
+                    {
+                        "profile": "host",
+                        "isHost": True,
+                        "replyWithoutMention": True,
+                    },
+                    *[
+                        {
+                            "profile": f"automatic-{index}",
+                            "replyWithoutMention": True,
+                        }
+                        for index in range(1, 8)
+                    ],
+                ],
+            })
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+
+            response = self._send(store, room["id"], "普通消息", [])
+
+            self.assertEqual(len(response["runs"]), 8)
+            self.assertEqual(
+                {run["agentId"] for run in response["runs"]},
+                {agent["id"] for agent in agents.values()},
+            )
+            with store.connection() as connection:
+                flags = connection.execute(
+                    """SELECT agent_id, required_reply FROM group_agent_runs
+                    WHERE root_message_id = ? ORDER BY agent_id""",
+                    (response["message"]["id"],),
+                ).fetchall()
+            self.assertEqual(sum(row["required_reply"] for row in flags), 1)
+            self.assertEqual(
+                next(row["agent_id"] for row in flags if row["required_reply"]),
+                agents["host"]["id"],
+            )
+
+    def test_host_switch_is_atomic_and_idempotent_replay_is_frozen(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "切换群",
+                "cwd": "",
+                "agents": [
+                    {"profile": "one", "isHost": True},
+                    {"profile": "two"},
+                    {"profile": "three"},
+                ],
+            })
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            with self.assertRaisesRegex(
+                STORE_MODULE.GroupConflictError, "cannot be cleared"
+            ):
+                store.update_agent(
+                    room["id"],
+                    agents["one"]["id"],
+                    {"requestId": new_id(), "isHost": False},
+                )
+
+            before = store.latest_cursor()
+            switch_request = new_id()
+            first = store.update_agent(
+                room["id"],
+                agents["two"]["id"],
+                {"requestId": switch_request, "isHost": True},
+            )
+            self.assertIs(first["isHost"], True)
+            events = store.events_after(before, 10)
+            agent_events = [
+                event for event in events if event["eventType"] == "agent.updated"
+            ]
+            self.assertEqual(
+                [(event["payload"]["id"], event["payload"]["isHost"]) for event in agent_events],
+                [(agents["one"]["id"], False), (agents["two"]["id"], True)],
+            )
+
+            store.update_agent(
+                room["id"],
+                agents["three"]["id"],
+                {"requestId": new_id(), "isHost": True},
+            )
+            replay = store.update_agent(
+                room["id"],
+                agents["two"]["id"],
+                {"requestId": switch_request, "isHost": True},
+            )
+            self.assertEqual(replay, first)
+            self.assertIs(replay["isHost"], True)
+            current = store.get_room(room["id"])["agents"]
+            self.assertEqual(
+                [agent["id"] for agent in current if agent["isHost"]],
+                [agents["three"]["id"]],
+            )
+
+    def test_host_disable_delete_promotion_and_no_replacement_rejection(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "顺延群",
+                "cwd": "",
+                "agents": [
+                    {"profile": "host", "isHost": True},
+                    {"profile": "second"},
+                    {"profile": "third"},
+                ],
+            })
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            expected_after_disable = min(
+                (agents["second"], agents["third"]),
+                key=lambda agent: (agent["createdAt"], agent["id"]),
+            )
+            store.update_agent(
+                room["id"],
+                agents["host"]["id"],
+                {"requestId": new_id(), "enabled": False},
+            )
+            current = store.get_room(room["id"])["agents"]
+            self.assertEqual(
+                [agent["id"] for agent in current if agent["isHost"]],
+                [expected_after_disable["id"]],
+            )
+
+            store.delete_agent(
+                room["id"], expected_after_disable["id"], {"requestId": new_id()}
+            )
+            current = store.get_room(room["id"])["agents"]
+            self.assertEqual(sum(agent["isHost"] for agent in current), 1)
+            self.assertTrue(next(agent for agent in current if agent["isHost"])["enabled"])
+
+            sole = store.create_room({
+                "requestId": new_id(),
+                "name": "无替代群",
+                "cwd": "",
+                "agents": [
+                    {"profile": "host", "isHost": True},
+                    {"profile": "disabled"},
+                ],
+            })
+            sole_agents = {agent["profile"]: agent for agent in sole["agents"]}
+            store.update_agent(
+                sole["id"],
+                sole_agents["disabled"]["id"],
+                {"requestId": new_id(), "enabled": False},
+            )
+            with self.assertRaisesRegex(
+                STORE_MODULE.GroupConflictError, "enabled replacement"
+            ):
+                store.update_agent(
+                    sole["id"],
+                    sole_agents["host"]["id"],
+                    {"requestId": new_id(), "enabled": False},
+                )
+            with self.assertRaisesRegex(
+                STORE_MODULE.GroupConflictError, "enabled replacement"
+            ):
+                store.delete_agent(
+                    sole["id"],
+                    sole_agents["host"]["id"],
+                    {"requestId": new_id()},
+                )
+
+    def test_enabling_agent_promotes_it_when_migrated_host_is_unavailable(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "恢复群",
+                "cwd": "",
+                "agents": [{"profile": "old"}, {"profile": "new"}],
+            })
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            with store.connection() as connection:
+                connection.execute(
+                    "UPDATE group_agents SET enabled = 0 WHERE room_id = ?",
+                    (room["id"],),
+                )
+            promoted = store.update_agent(
+                room["id"],
+                agents["new"]["id"],
+                {"requestId": new_id(), "enabled": True},
+            )
+            self.assertIs(promoted["isHost"], True)
+            current = store.get_room(room["id"])["agents"]
+            self.assertEqual(
+                [agent["id"] for agent in current if agent["isHost"]],
+                [agents["new"]["id"]],
+            )
+
+    def test_v4_migration_selects_host_and_preserves_counts(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            path = Path(directory) / "group.db"
+            store = GroupStore(path)
+            store.initialize()
+            preferred_room = store.create_room({
+                "requestId": new_id(),
+                "name": "优先级群",
+                "cwd": "",
+                "agents": [
+                    {"profile": "plain"},
+                    {"profile": "auto-one", "replyWithoutMention": True},
+                    {"profile": "auto-two", "replyWithoutMention": True},
+                ],
+            })
+            disabled_room = store.create_room({
+                "requestId": new_id(),
+                "name": "全禁用群",
+                "cwd": "",
+                "agents": [{"profile": "old-one"}, {"profile": "old-two"}],
+            })
+            preferred_agents = {
+                agent["profile"]: agent for agent in preferred_room["agents"]
+            }
+            expected_preferred = min(
+                (preferred_agents["auto-one"], preferred_agents["auto-two"]),
+                key=lambda agent: (agent["createdAt"], agent["id"]),
+            )
+            expected_disabled = min(
+                disabled_room["agents"],
+                key=lambda agent: (agent["createdAt"], agent["id"]),
+            )
+            with store.connection() as connection:
+                connection.execute(
+                    "UPDATE group_agents SET enabled = 0 WHERE room_id = ?",
+                    (disabled_room["id"],),
+                )
+                before_counts = {
+                    table: connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
+                    for table in (
+                        "group_rooms",
+                        "group_agents",
+                        "group_messages",
+                        "group_agent_runs",
+                        "group_topics",
+                    )
+                }
+                connection.execute("DROP INDEX idx_group_agents_room_host")
+                connection.execute("ALTER TABLE group_agents DROP COLUMN is_host")
+                connection.execute(
+                    "ALTER TABLE group_agent_runs DROP COLUMN required_reply"
+                )
+                connection.execute(
+                    "UPDATE group_meta SET value = '4' WHERE key = 'schema_version'"
+                )
+
+            migrated = GroupStore(path)
+            migrated.initialize()
+            self.assertEqual(migrated.schema_version(), 5)
+            with migrated.connection() as connection:
+                after_counts = {
+                    table: connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
+                    for table in before_counts
+                }
+                preferred_host = connection.execute(
+                    "SELECT * FROM group_agents WHERE room_id = ? AND is_host = 1",
+                    (preferred_room["id"],),
+                ).fetchone()
+                disabled_host = connection.execute(
+                    "SELECT * FROM group_agents WHERE room_id = ? AND is_host = 1",
+                    (disabled_room["id"],),
+                ).fetchone()
+                replay = json.loads(
+                    connection.execute(
+                        """SELECT response_json FROM group_idempotency
+                        WHERE operation = 'room.created' AND response_json LIKE ?""",
+                        (f'%{preferred_room["id"]}%',),
+                    ).fetchone()[0]
+                )
+                with self.assertRaises(sqlite3.IntegrityError):
+                    connection.execute(
+                        "UPDATE group_agents SET is_host = 1 WHERE id != ? AND room_id = ?",
+                        (preferred_host["id"], preferred_room["id"]),
+                    )
+            self.assertEqual(after_counts, before_counts)
+            self.assertEqual(preferred_host["id"], expected_preferred["id"])
+            self.assertEqual(disabled_host["id"], expected_disabled["id"])
+            self.assertEqual(disabled_host["enabled"], 0)
+            self.assertEqual(sum(agent["isHost"] for agent in replay["agents"]), 1)
+            self.assertEqual(
+                next(agent["id"] for agent in replay["agents"] if agent["isHost"]),
+                expected_preferred["id"],
+            )
+
+    def test_concurrent_host_switches_preserve_unique_host(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "并发群",
+                "cwd": "",
+                "agents": [
+                    {"profile": "host", "isHost": True},
+                    {"profile": "left"},
+                    {"profile": "right"},
+                ],
+            })
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            barrier = threading.Barrier(2)
+
+            def switch(profile: str) -> dict[str, object]:
+                barrier.wait()
+                return store.update_agent(
+                    room["id"],
+                    agents[profile]["id"],
+                    {"requestId": new_id(), "isHost": True},
+                )
+
+            with ThreadPoolExecutor(max_workers=2) as executor:
+                results = list(executor.map(switch, ("left", "right")))
+            self.assertTrue(all(result["isHost"] for result in results))
+            current = store.get_room(room["id"])["agents"]
+            self.assertEqual(sum(agent["isHost"] for agent in current), 1)
+
+    def test_required_host_prompt_fallback_and_failure_are_visible(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "必答群",
+                "cwd": "",
+                "agents": [{"profile": "host", "isHost": True}],
+            })
+            created = self._send(store, room["id"], "请主持", [])
+            [run] = created["runs"]
+            claimed = store.claim_next_runnable_run()
+            projection = store.read_run_projection(claimed["id"])
+            self.assertIs(projection["run"]["requiredReply"], True)
+            prompt = ORCHESTRATOR.build_run_prompt(
+                projection, store.get_room(room["id"])["agents"]
+            )
+            envelope = json.loads(prompt.rsplit("GROUP_CONTEXT_JSON=", 1)[1])
+            self.assertIs(envelope["run"]["requiredReply"], True)
+            self.assertIn("唯一主持人", prompt)
+            self.assertNotIn("[[YAOYAO_NO_REPLY_V1]]", prompt)
+
+            before = store.latest_cursor()
+            store.transition_run(run["id"], "failed", error="配置失败")
+            events = store.events_after(before, 10)
+            self.assertTrue(
+                any(event["eventType"] == "message.upsert" for event in events)
+            )
+            response = store.get_message(run["responseMessageId"])
+            self.assertEqual(response["status"], "failed")
+            self.assertEqual(response["error"], "配置失败")
+
+        fallback, clarification = asyncio.run(self._exercise_required_reply_runtime())
+        self.assertEqual(fallback[-1]["content"], ORCHESTRATOR._HOST_FALLBACK_REPLY)
+        self.assertTrue(fallback[-1]["publish"])
+        self.assertEqual(clarification[-1]["content"], "")
+
+    @staticmethod
+    async def _exercise_required_reply_runtime() -> tuple[
+        list[dict[str, object]], list[dict[str, object]]
+    ]:
+        class FakeStore:
+            def __init__(self) -> None:
+                self.upserts: list[dict[str, object]] = []
+
+            def upsert_agent_message(
+                self, _run_id: str, **kwargs: object
+            ) -> dict[str, object]:
+                self.upserts.append(dict(kwargs))
+                return dict(kwargs)
+
+            def create_gateway_interaction(
+                self, _run_id: str, **_kwargs: object
+            ) -> dict[str, object]:
+                return {"id": new_id()}
+
+            def expire_interaction(self, interaction_id: str) -> dict[str, object]:
+                return {"id": interaction_id}
+
+        async def complete(*, clarify: bool) -> list[dict[str, object]]:
+            store = FakeStore()
+            orchestrator = ORCHESTRATOR.GroupOrchestrator(store)
+            state = ORCHESTRATOR._RuntimeState(
+                run_id=new_id(),
+                room_id=new_id(),
+                agent_id=new_id(),
+                runtime_id="runtime",
+                generation=1,
+                expected_stored_id=None,
+                session_stored_id="stored",
+                expected_context_seq=0,
+                through_seq=0,
+                reply_mode="automatic",
+                required_reply=True,
+                automatic_published=True,
+            )
+            if clarify:
+                await orchestrator._apply_runtime_event(
+                    state,
+                    ORCHESTRATOR._GatewayEvent(
+                        runtime_id="runtime",
+                        generation=1,
+                        event_type="clarify.request",
+                        payload={"request_id": "clarify-1", "question": "请补充"},
+                    ),
+                )
+            await orchestrator._apply_runtime_event(
+                state,
+                ORCHESTRATOR._GatewayEvent(
+                    runtime_id="runtime",
+                    generation=1,
+                    event_type="message.complete",
+                    payload={
+                        "status": "complete",
+                        "text": "[[YAOYAO_NO_REPLY_V1]]",
+                    },
+                ),
+            )
+            if state.grace_task is not None:
+                state.grace_task.cancel()
+                await asyncio.gather(state.grace_task, return_exceptions=True)
+            return store.upserts
+
+        return await complete(clarify=False), await complete(clarify=True)
+
+    @staticmethod
+    def _send(
+        store: GroupStore, room_id: str, content: str, mention_ids: list[str]
+    ) -> dict[str, object]:
+        return store.create_human_message(
+            room_id,
+            request_id=new_id(),
+            client_message_id=new_id(),
+            content=content,
+            mention_agent_ids=mention_ids,
+        )
+
+    @staticmethod
+    def _run_modes(result: dict[str, object]) -> dict[str, str]:
+        return {run["agentId"]: run["replyMode"] for run in result["runs"]}
+
+    @staticmethod
+    def _insert_completed_source(
+        store: GroupStore, room_id: str, agent_id: str, *, content: str
+    ) -> str:
+        message_id, run_id = new_id(), new_id()
+        with store.write_transaction() as connection:
+            now = store._now()
+            store._ensure_topic(connection, room_id, message_id, content, now)
+            connection.execute(
+                """INSERT INTO group_messages
+                (id, room_id, topic_id, sender_kind, sender_id, sender_name,
+                 root_message_id, reply_to_message_id, client_message_id,
+                 content, reasoning, tool_state_json, status, error, visible,
+                 created_at, updated_at)
+                VALUES (?, ?, ?, 'agent', ?, 'source', ?, NULL, NULL, ?, '', '[]',
+                        'completed', '', 1, ?, ?)""",
+                (
+                    message_id,
+                    room_id,
+                    message_id,
+                    agent_id,
+                    message_id,
+                    content,
+                    now,
+                    now,
+                ),
+            )
+            connection.execute(
+                """INSERT INTO group_agent_runs
+                (id, room_id, topic_id, agent_id, trigger_message_id,
+                 response_message_id, root_message_id, depth, reply_mode,
+                 required_reply, status, runtime_session_id, error, created_at,
+                 updated_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'mentioned', 0, 'completed',
+                        NULL, '', ?, ?)""",
+                (
+                    run_id,
+                    room_id,
+                    message_id,
+                    agent_id,
+                    message_id,
+                    message_id,
+                    message_id,
+                    now,
+                    now,
+                ),
+            )
+            store._record_cascade_plan(
+                connection,
+                source=store._run_row(connection, run_id),
+                source_message=store._message_row(connection, message_id),
+                created_at=now,
+            )
+        return run_id
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 22 - 13
hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py

@@ -204,8 +204,8 @@ class GroupSettingsContractTests(unittest.TestCase):
                     store.get_room(room["id"])["agents"],
                 )
 
-    def test_protocol_v4_advertises_reply_round_contract(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 4)
+    def test_protocol_v5_advertises_reply_round_contract(self) -> None:
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 5)
         self.assertEqual(
             PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
         )
@@ -226,6 +226,7 @@ class GroupSettingsContractTests(unittest.TestCase):
         self.assertEqual(create.max_reply_rounds, 3)
         self.assertIsNone(create.agents[0].display_name)
         self.assertFalse(create.agents[0].reply_without_mention)
+        self.assertFalse(create.agents[0].is_host)
         for invalid in (0, -2, 101, True, "3"):
             with self.subTest(invalid=invalid), self.assertRaises(ValueError):
                 PROTOCOL.CreateRoomRequest.model_validate(
@@ -559,7 +560,7 @@ class GroupSettingsContractTests(unittest.TestCase):
                 }
             )
             by_profile = {agent["profile"]: agent for agent in room["agents"]}
-            planner, coder = by_profile["planner"], by_profile["coder"]
+            planner = by_profile["planner"]
             created = store.create_human_message(
                 room["id"],
                 request_id=request_id(),
@@ -568,7 +569,7 @@ class GroupSettingsContractTests(unittest.TestCase):
                 mention_agent_ids=[planner["id"]],
             )
             modes = {run["agentId"]: run["replyMode"] for run in created["runs"]}
-            self.assertEqual(modes, {planner["id"]: "mentioned", coder["id"]: "automatic"})
+            self.assertEqual(modes, {planner["id"]: "mentioned"})
             listed = store.list_messages(room["id"])
             self.assertEqual([message["senderId"] for message in listed], ["human", planner["id"]])
 
@@ -578,7 +579,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             self._create_v1_database(path)
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 4)
+            self.assertEqual(store.schema_version(), 5)
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             with store.connection() as connection:
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()
@@ -588,7 +589,9 @@ class GroupSettingsContractTests(unittest.TestCase):
                 ledger = connection.execute("SELECT * FROM group_idempotency").fetchone()
             self.assertEqual(room["max_reply_rounds"], 3)
             self.assertEqual(agent["reply_without_mention"], 0)
+            self.assertEqual(agent["is_host"], 1)
             self.assertEqual(run["reply_mode"], "mentioned")
+            self.assertEqual(run["required_reply"], 0)
             self.assertEqual(message["visible"], 1)
             self.assertEqual(ledger["response_json"], "{}")
 
@@ -807,10 +810,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             }
             self.assertEqual(
                 modes,
-                {
-                    agents["coder"]["id"]: "mentioned",
-                    agents["reviewer"]["id"]: "automatic",
-                },
+                {agents["coder"]["id"]: "mentioned"},
             )
 
     def test_round_limit_and_unlimited_mode(self) -> None:
@@ -1360,7 +1360,8 @@ class GroupSettingsContractTests(unittest.TestCase):
                     "name": "研发群",
                     "cwd": "",
                     "agents": [
-                        {"profile": "reviewer", "replyWithoutMention": True}
+                        {"profile": "host"},
+                        {"profile": "reviewer", "replyWithoutMention": True},
                     ],
                 }
             )
@@ -1371,7 +1372,12 @@ class GroupSettingsContractTests(unittest.TestCase):
                 content="普通消息",
                 mention_agent_ids=[],
             )
-            [run] = created["runs"]
+            reviewer = next(
+                agent for agent in room["agents"] if agent["profile"] == "reviewer"
+            )
+            run = next(
+                item for item in created["runs"] if item["agentId"] == reviewer["id"]
+            )
             before = store.latest_cursor()
             first = store.transition_run(run["id"], "failed", error="失败")
             second = store.transition_run(run["id"], "failed", error="失败")
@@ -1386,8 +1392,11 @@ class GroupSettingsContractTests(unittest.TestCase):
                 any(event["eventType"] == "message.upsert" for event in events)
             )
             self.assertEqual(
-                [message["senderKind"] for message in store.list_messages(room["id"])],
-                ["human"],
+                [message["senderId"] for message in store.list_messages(room["id"])],
+                [
+                    "human",
+                    next(agent["id"] for agent in room["agents"] if agent["isHost"]),
+                ],
             )
 
     def test_automatic_consumer_waits_without_zero_timeout_and_accepts_terminal(self) -> None:

+ 32 - 8
hermes-plugins/yaoyao/dashboard/tests/test_group_topics_contract.py

@@ -33,7 +33,7 @@ def new_id() -> str:
 
 class GroupTopicsContractTests(unittest.TestCase):
     def test_protocol_and_router_advertise_topics(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 4)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 5)
         self.assertIn("topic.updated", PROTOCOL.EVENT_TYPES)
         request = PROTOCOL.SendMessageRequest.model_validate({
             "requestId": new_id(),
@@ -99,7 +99,13 @@ class GroupTopicsContractTests(unittest.TestCase):
             events = store.events_after(before, 10)
             self.assertEqual(
                 [event["eventType"] for event in events],
-                ["message.upsert", "topic.updated"],
+                [
+                    "message.upsert",
+                    "topic.updated",
+                    "message.upsert",
+                    "topic.updated",
+                    "run.updated",
+                ],
             )
             self.assertEqual(events[0]["payload"]["topicId"], topic_a)
             self.assertEqual(
@@ -157,10 +163,20 @@ class GroupTopicsContractTests(unittest.TestCase):
 
             topic_a_messages = store.list_messages(room["id"], topic_id=topic_a)
             self.assertEqual(
-                [message["id"] for message in topic_a_messages],
+                [
+                    message["id"]
+                    for message in topic_a_messages
+                    if message["senderKind"] == "human"
+                ],
                 [first["message"]["id"], second["message"]["id"]],
             )
-            self.assertEqual(len(store.list_messages(room["id"])), 5)
+            self.assertEqual(
+                sum(
+                    message["senderKind"] == "human"
+                    for message in store.list_messages(room["id"])
+                ),
+                5,
+            )
             page_one = store.list_topics(room["id"], limit=2, cursor=None)
             page_two = store.list_topics(
                 room["id"], limit=2, cursor=page_one.next_cursor
@@ -171,7 +187,7 @@ class GroupTopicsContractTests(unittest.TestCase):
             summary_a = next(item for item in summaries if item["id"] == topic_a)
             self.assertEqual(summary_a["title"], "第一个话题")
             self.assertEqual(summary_a["preview"], "第一个话题的后续")
-            self.assertEqual(summary_a["messageCount"], 2)
+            self.assertEqual(summary_a["messageCount"], 4)
 
             other_room = store.create_room({
                 "requestId": new_id(),
@@ -1146,7 +1162,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 4)
+            self.assertEqual(store.schema_version(), 5)
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
@@ -1172,7 +1188,7 @@ class GroupTopicsContractTests(unittest.TestCase):
                     ).fetchone()[0]
                 )
             self.assertEqual(dict(room), expected_room)
-            self.assertEqual(dict(agent), expected_agent)
+            self.assertEqual(dict(agent), {**expected_agent, "is_host": 1})
             self.assertEqual(
                 (room["name"], room["max_reply_rounds"], room["archived"]),
                 ("保留群", 7, 1),
@@ -1325,7 +1341,10 @@ class GroupTopicsContractTests(unittest.TestCase):
         )
         connection.execute(
             """INSERT INTO group_agent_runs_early_fixture
-            SELECT * FROM group_agent_runs"""
+            SELECT id, room_id, topic_id, agent_id, trigger_message_id,
+                   response_message_id, root_message_id, depth, reply_mode,
+                   status, runtime_session_id, error, created_at, updated_at
+            FROM group_agent_runs"""
         )
         connection.execute(
             """CREATE TABLE group_interactions_early_fixture (
@@ -1357,6 +1376,11 @@ class GroupTopicsContractTests(unittest.TestCase):
         connection.execute(
             "ALTER TABLE group_interactions_early_fixture RENAME TO group_interactions"
         )
+        connection.execute("DROP INDEX IF EXISTS idx_group_agents_room_host")
+        connection.execute("ALTER TABLE group_agents DROP COLUMN is_host")
+        connection.execute(
+            "UPDATE group_meta SET value = '4' WHERE key = 'schema_version'"
+        )
 
     @staticmethod
     def _repair_snapshot(connection: sqlite3.Connection) -> dict[str, object]:

+ 1 - 0
src/client/api/groups.ts

@@ -45,6 +45,7 @@ export interface AgentSeed {
   displayName: string
   description?: string
   replyWithoutMention?: boolean
+  isHost?: boolean
   model?: string | null
   provider?: string | null
   reasoningEffort?: string | null

+ 38 - 6
src/client/components/groups/CreateGroupDialog.vue

@@ -2,31 +2,55 @@
 import { computed, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 
-const props = withDefaults(defineProps<{ open: boolean; profiles: string[]; busy?: boolean }>(), { busy: false })
-const emit = defineEmits<{ close: []; create: [payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number }] }>()
+interface CreateGroupPayload {
+  name: string
+  profiles: string[]
+  autoReply: boolean
+  replyRounds: number
+  hostProfile?: string
+}
+
+const props = withDefaults(defineProps<{ open: boolean; profiles: string[]; hostEnabled?: boolean; busy?: boolean }>(), {
+  hostEnabled: false,
+  busy: false,
+})
+const emit = defineEmits<{ close: []; create: [payload: CreateGroupPayload] }>()
 
 const name = ref('')
 const selected = ref<string[]>([])
+const hostProfile = ref('')
 const autoReply = ref(true)
 const replyRounds = ref(3)
-const valid = computed(() => name.value.trim().length > 0 && selected.value.length >= 1 && selected.value.length <= 8)
+const valid = computed(() => name.value.trim().length > 0
+  && selected.value.length >= 1
+  && selected.value.length <= 8
+  && (!props.hostEnabled || selected.value.includes(hostProfile.value)))
 
 watch(() => props.open, open => {
   if (!open) return
   name.value = ''
   selected.value = props.profiles.slice(0, 1)
+  hostProfile.value = selected.value[0] ?? ''
   autoReply.value = true
   replyRounds.value = 3
-})
+}, { immediate: true })
 
 function toggle(profile: string) {
   if (selected.value.includes(profile)) selected.value = selected.value.filter(item => item !== profile)
   else if (selected.value.length < 8) selected.value.push(profile)
+  if (!selected.value.includes(hostProfile.value)) hostProfile.value = selected.value[0] ?? ''
 }
 
 function create() {
   if (!valid.value) return
-  emit('create', { name: name.value.trim(), profiles: selected.value, autoReply: autoReply.value, replyRounds: replyRounds.value })
+  const payload: CreateGroupPayload = {
+    name: name.value.trim(),
+    profiles: selected.value,
+    autoReply: autoReply.value,
+    replyRounds: replyRounds.value,
+  }
+  if (props.hostEnabled) payload.hostProfile = hostProfile.value
+  emit('create', payload)
 }
 </script>
 
@@ -44,8 +68,14 @@ function create() {
             </button>
             <p v-if="!profiles.length">当前没有可用 Agent,请先在 Hermes 配置 Profile。</p>
           </div>
+          <label v-if="hostEnabled" class="host-picker">
+            <span>主持人<small>用户没有明确 @ 时,主持人始终负责回应。</small></span>
+            <select v-model="hostProfile" aria-label="主持人">
+              <option v-for="profile in selected" :key="profile" :value="profile">{{ profile }}</option>
+            </select>
+          </label>
           <div class="dialog-settings">
-            <label><input v-model="autoReply" type="checkbox" />启用自动回复</label>
+            <label><input v-model="autoReply" type="checkbox" :aria-label="hostEnabled ? '所有成员无需 @ 也回复' : '启用自动回复'" />{{ hostEnabled ? '所有成员无需 @ 也回复' : '启用自动回复' }}</label>
             <label>最多轮数 <input v-model.number="replyRounds" type="number" min="1" max="12" /></label>
           </div>
           <footer><button class="quiet-button" type="button" @click="emit('close')">取消</button><button class="solid-button" type="button" :disabled="!valid || busy" @click="create">{{ busy ? '创建中…' : '创建群聊' }}</button></footer>
@@ -61,7 +91,9 @@ function create() {
 header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; } header small { color: var(--text-muted); font-size: 9px; letter-spacing: .06em; } h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
 .field { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: 10px; }.field input { height: 38px; padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; outline: none; background: var(--surface-soft); color: var(--text-primary); }.field input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .agent-picker { margin-top: 17px; }.agent-picker__heading { display: flex; justify-content: space-between; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; }.agent-picker__heading small { color: var(--text-muted); }.agent-picker > button { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.agent-picker > button:hover, .agent-picker > button.selected { background: var(--surface-soft); }.agent-picker > button:disabled { opacity: .35; }.agent-picker > button > span { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; }.agent-picker > button strong { flex: 1; font-size: 11px; }.agent-picker p { color: var(--text-muted); font-size: 10px; }
+.host-picker { display: grid; grid-template-columns: minmax(0, 1fr) 148px; align-items: center; gap: 12px; margin-top: 13px; padding: 11px; border: 1px solid var(--line); border-radius: 10px; color: var(--text-secondary); font-size: 10px; }.host-picker > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; font-weight: 650; }.host-picker small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.45; }.host-picker select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 10px; }.host-picker select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .dialog-settings { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; padding: 11px; border-radius: 10px; background: var(--surface-soft); }.dialog-settings label { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 10px; }.dialog-settings input[type="checkbox"] { accent-color: var(--accent); }.dialog-settings input[type="number"] { width: 51px; padding: 4px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-raised); color: var(--text-primary); text-align: center; }
 footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
 .dialog-fade-enter-active, .dialog-fade-leave-active { transition: opacity 140ms ease; }.dialog-fade-enter-active .create-dialog, .dialog-fade-leave-active .create-dialog { transition: transform 170ms var(--ease-out); }.dialog-fade-enter-from, .dialog-fade-leave-to { opacity: 0; }.dialog-fade-enter-from .create-dialog, .dialog-fade-leave-to .create-dialog { transform: translateY(8px) scale(.985); }
+@media (max-width: 480px) { .host-picker { grid-template-columns: 1fr; }.host-picker select { width: 100%; } }
 </style>

+ 37 - 8
src/client/components/groups/GroupManager.vue

@@ -5,7 +5,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import type { UiRoom } from './types'
 
 type AgentSettingsPatch = Partial<Pick<GroupAgent,
-  'displayName' | 'description' | 'enabled' | 'replyWithoutMention' | 'model' | 'provider' | 'reasoningEffort' | 'fastMode'>>
+  'displayName' | 'description' | 'enabled' | 'replyWithoutMention' | 'isHost' | 'model' | 'provider' | 'reasoningEffort' | 'fastMode'>>
 
 type FastModeDraft = '' | 'true' | 'false'
 
@@ -31,16 +31,18 @@ const REASONING_OPTIONS = [
   { value: 'ultra', label: 'Ultra' },
 ]
 
-const props = defineProps<{
+const props = withDefaults(defineProps<{
   room: UiRoom
   agents: GroupAgent[]
+  hostEnabled?: boolean
   availableProfiles?: string[]
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsLoading?: Record<string, boolean>
   modelOptionsError?: Record<string, string>
   agentUpdateError?: Record<string, string>
+  managerError?: string
   busy?: boolean
-}>()
+}>(), { hostEnabled: false, busy: false })
 
 const emit = defineEmits<{
   updateRoom: [patch: Partial<UiRoom>]
@@ -58,6 +60,7 @@ const expandedAgentId = ref('')
 const agentDrafts = reactive<Record<string, AgentDraft>>({})
 const dirtyAgents = reactive(new Set<string>())
 const selectedAgent = computed(() => props.agents.find(agent => agent.id === expandedAgentId.value))
+const hostAgent = computed(() => props.agents.find(agent => agent.isHost))
 
 function modelKey(provider?: string | null, model?: string | null): string {
   return provider && model ? JSON.stringify([provider, model]) : ''
@@ -113,6 +116,22 @@ function saveRoom() {
   })
 }
 
+function selectHost(event: Event) {
+  const id = (event.target as HTMLSelectElement).value
+  if (!id || id === hostAgent.value?.id) return
+  emit('updateAgent', id, { isHost: true })
+}
+
+function canRemoveAgent(agent: GroupAgent): boolean {
+  if (props.agents.length <= 1) return false
+  if (!props.hostEnabled || !agent.isHost) return true
+  return props.agents.some(candidate => candidate.id !== agent.id && candidate.enabled)
+}
+
+function removeAgentTitle(agent: GroupAgent): string {
+  return canRemoveAgent(agent) ? '移除' : agent.isHost ? '需要另一位已启用成员才能移除主持人' : '房间必须保留至少一位成员'
+}
+
 function openAgentSettings(agent: GroupAgent) {
   expandedAgentId.value = agent.id
   if (!dirtyAgents.has(agent.id)) agentDrafts[agent.id] = draftFrom(agent)
@@ -192,13 +211,20 @@ function statusLabel(status: GroupAgent['status']): string {
 
     <section>
       <div class="section-heading"><h3>成员 <em>{{ agents.length }}/8</em></h3></div>
+      <label v-if="hostEnabled" class="host-selector">
+        <span>主持人<small>用户没有明确 @ 时始终负责回应;“无需 @ 也回复”仍独立生效。</small></span>
+        <select :value="hostAgent?.id || ''" aria-label="主持人" :disabled="busy" @change="selectHost">
+          <option v-for="agent in agents" :key="agent.id" :value="agent.id" :disabled="!agent.enabled && !agent.isHost">{{ agent.displayName }}</option>
+        </select>
+      </label>
+      <p v-if="managerError" class="agent-save-error" role="alert">{{ managerError }}</p>
       <div class="agent-list">
         <article v-for="agent in agents" :key="agent.id">
           <span class="agent-avatar">{{ agent.displayName.slice(0, 1).toUpperCase() }}<i :class="`status-${agent.status}`" /></span>
-          <span class="agent-copy"><strong>{{ agent.displayName }}</strong><small>{{ agent.profile }} · {{ statusLabel(agent.status) }}</small></span>
+          <span class="agent-copy"><span class="agent-name-line"><strong>{{ agent.displayName }}</strong><em v-if="hostEnabled && agent.isHost" class="host-badge">主持人</em></span><small>{{ agent.profile }} · {{ statusLabel(agent.status) }}</small></span>
           <button class="agent-action" type="button" :title="`设置 ${agent.displayName}`" :aria-label="`设置${agent.displayName}`" @click="openAgentSettings(agent)"><AppIcon name="settings" :size="14" /></button>
           <button v-if="agent.status === 'running' || agent.status === 'queued'" class="agent-action" type="button" title="中断" @click="emit('interruptAgent', agent.id)"><AppIcon name="stop" :size="13" /></button>
-          <button class="agent-action danger" type="button" title="移除" :disabled="agents.length <= 1" @click="emit('removeAgent', agent.id)"><AppIcon name="trash" :size="14" /></button>
+          <button class="agent-action danger" type="button" :title="removeAgentTitle(agent)" :aria-label="`移除${agent.displayName}`" :disabled="!canRemoveAgent(agent) || busy" @click="emit('removeAgent', agent.id)"><AppIcon name="trash" :size="14" /></button>
         </article>
       </div>
 
@@ -222,7 +248,7 @@ function statusLabel(status: GroupAgent['status']): string {
         <header>
           <span>
             <small>Agent 设置</small>
-            <strong>{{ selectedAgent.displayName }}</strong>
+            <strong class="settings-agent-name">{{ selectedAgent.displayName }}<em v-if="hostEnabled && selectedAgent.isHost" class="host-badge">主持人</em></strong>
           </span>
           <button class="agent-settings-close" type="button" aria-label="关闭 Agent 设置" title="关闭 Agent 设置" @click="closeAgentSettings"><AppIcon name="close" :size="17" /></button>
         </header>
@@ -257,8 +283,9 @@ function statusLabel(status: GroupAgent['status']): string {
           </div>
           <div class="editor-toggles">
             <label><input v-model="agentDrafts[selectedAgent.id].enabled" type="checkbox" aria-label="启用" @change="markDirty(selectedAgent.id)" />启用</label>
-            <label><input v-model="agentDrafts[selectedAgent.id].replyWithoutMention" type="checkbox" aria-label="自动回复" @change="markDirty(selectedAgent.id)" />自动回复</label>
+            <label><input v-model="agentDrafts[selectedAgent.id].replyWithoutMention" type="checkbox" :aria-label="hostEnabled ? '无需 @ 也回复' : '自动回复'" @change="markDirty(selectedAgent.id)" />{{ hostEnabled ? '无需 @ 也回复' : '自动回复' }}</label>
           </div>
+          <p v-if="hostEnabled" class="host-explanation">{{ selectedAgent.isHost ? '主持人始终处理用户无 @ 消息;此开关仅决定是否自动参与 Agent 发出的无 @ 消息。' : '开启后,该成员会自动参与未明确 @ 的消息;用户无 @ 消息仍由主持人兜底。' }}</p>
           <p v-if="agentUpdateError?.[selectedAgent.id]" class="agent-save-error" role="alert">{{ agentUpdateError[selectedAgent.id] }}</p>
           <div class="editor-actions">
             <button class="quiet-button" type="button" :disabled="busy || !hasAgentChanges(selectedAgent)" @click="resetAgent(selectedAgent)">取消更改</button>
@@ -279,12 +306,14 @@ h3 { margin: 0 0 13px; color: var(--text-secondary); font-size: 10px; font-weigh
 label { display: flex; flex-direction: column; gap: 5px; margin: 0 0 11px; color: var(--text-muted); font-size: 10px; }
 input, textarea, select { width: 100%; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; resize: vertical; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; } input:focus, textarea:focus, select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .rounds { flex-direction: row; align-items: center; justify-content: space-between; }.rounds > span { display: flex; flex-direction: column; gap: 2px; }.rounds small { color: var(--text-muted); font-size: 9px; }.rounds input { width: 62px; text-align: center; }
+.host-selector { display: grid; grid-template-columns: minmax(0, 1fr) 128px; align-items: center; gap: 10px; margin-bottom: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); }.host-selector > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; color: var(--text-secondary); font-weight: 650; }.host-selector small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.4; }.host-selector select { padding: 7px 8px; background: var(--surface); }
 .agent-list { display: flex; flex-direction: column; gap: 7px; }.agent-list article { display: grid; grid-template-columns: 32px minmax(0,1fr) repeat(3, 28px); align-items: center; gap: 7px; padding: 8px; border: 1px solid transparent; border-radius: 10px; background: var(--surface-soft); }
 .agent-avatar { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }.agent-avatar i { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--surface-soft); border-radius: 50%; background: var(--text-muted); }.agent-avatar .status-running, .agent-avatar .status-queued { background: var(--warning); }.agent-avatar .status-idle { background: var(--success); }.agent-avatar .status-awaiting_input { background: var(--warning); }
-.agent-copy { display: flex; min-width: 0; flex-direction: column; }.agent-copy strong, .agent-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.agent-copy strong { font-size: 11px; }.agent-copy small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }
+.agent-copy { display: flex; min-width: 0; flex-direction: column; }.agent-name-line, .settings-agent-name { display: flex; min-width: 0; align-items: center; gap: 5px; }.agent-copy strong, .agent-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.agent-copy strong { min-width: 0; font-size: 11px; }.agent-copy small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }.host-badge { flex: 0 0 auto; padding: 2px 5px; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 9%, transparent); color: var(--accent); font-size: 8px; font-style: normal; font-weight: 650; line-height: 1.2; }
 .agent-action { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.agent-action:hover { background: var(--surface-hover); color: var(--text-primary); }.agent-action.danger:hover { color: var(--danger); }.agent-action:disabled { cursor: not-allowed; opacity: .25; }
 .agent-settings-backdrop { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 24px; background: color-mix(in srgb, var(--text-primary) 24%, transparent); }.agent-settings-dialog { width: min(100%, 520px); max-height: min(720px, calc(100vh - 48px)); overflow: auto; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: 0 24px 72px color-mix(in srgb, var(--text-primary) 24%, transparent); }.agent-settings-dialog header { min-height: 68px; padding: 0 20px; border-bottom: 1px solid var(--line); border-radius: 16px 16px 0 0; }.agent-settings-close { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; }.agent-settings-close:hover { background: var(--surface-hover); color: var(--text-primary); }.agent-editor { display: block; min-width: 0; margin: 0; padding: 20px; border: 0; }.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }.editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }.editor-grid > label:nth-child(1), .editor-grid > label:nth-child(2) { grid-column: 1 / -1; }.editor-grid label:last-child { margin-bottom: 7px; }.editor-note { margin: -1px 2px 0; color: var(--text-muted); font-size: 9px; }.editor-note.error { color: var(--danger); }
 .editor-toggles { display: flex; align-items: center; gap: 16px; padding: 3px 0 12px; }.editor-toggles label { display: flex; flex-direction: row; align-items: center; gap: 5px; margin: 0; color: var(--text-secondary); }.editor-toggles input { width: auto; margin: 0; accent-color: var(--accent); }
+.host-explanation { margin: -4px 0 12px; color: var(--text-muted); font-size: 9px; line-height: 1.5; }
 .agent-save-error { margin: 0 0 10px; color: var(--danger); font-size: 9px; line-height: 1.45; }
 .editor-actions { display: flex; justify-content: flex-end; gap: 7px; }.editor-actions button { min-height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer; font-size: 10px; }.editor-actions button:disabled { cursor: not-allowed; opacity: .35; }.save-agent { border: 1px solid var(--accent); background: var(--accent); color: var(--text-on-solid); }
 .add-agent { margin-top: 8px; }.add-agent select { cursor: pointer; }

+ 1 - 0
src/client/components/groups/types.ts

@@ -5,6 +5,7 @@ export type UiAgent = {
   enabled?: boolean
   status?: 'idle' | 'working' | 'offline' | 'error'
   autoReply?: boolean
+  isHost?: boolean
   replyRounds?: number
 }
 

+ 1 - 0
src/client/components/workspace/viewModels.ts

@@ -236,6 +236,7 @@ export function agentToUi(agent: GroupAgent): UiAgent {
     profile: agent.profile,
     enabled: agent.enabled,
     autoReply: agent.replyWithoutMention,
+    isHost: agent.isHost,
     status: agent.status === 'running' || agent.status === 'queued' ? 'working' : agent.status === 'unknown' ? 'offline' : 'idle',
   }
 }

+ 15 - 7
src/client/stores/groups.ts

@@ -14,7 +14,7 @@ import {
 import { GroupEventSocket } from '@/api/realtime'
 import { ScopedCache } from '@/utils/cache'
 import {
-  applyGroupEnvelope, snapshotGroupRoom, upsertGroupMessage, upsertGroupTopic, type GroupProtocolState,
+  applyGroupEnvelope, convergeGroupAgents, snapshotGroupRoom, upsertGroupMessage, upsertGroupTopic, type GroupProtocolState,
 } from '@/utils/groupProtocol'
 import { createUuid } from '@/utils/id'
 import { ApiError } from '@/api/client'
@@ -60,6 +60,7 @@ export const useGroupsStore = defineStore('groups', () => {
   const rooms = computed(() => protocol.value.rooms)
   const selectedRoom = computed(() => selectedRoomId.value ? protocol.value.roomDetails[selectedRoomId.value] : undefined)
   const topicProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 4)
+  const hostProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 5)
   const topics = computed(() => selectedRoomId.value ? protocol.value.topicsByRoom[selectedRoomId.value] ?? [] : [])
   const selectedTopic = computed(() => selectedTopicId.value ? topics.value.find(topic => topic.id === selectedTopicId.value) : undefined)
   const messages = computed(() => topicProtocol.value
@@ -451,13 +452,9 @@ export const useGroupsStore = defineStore('groups', () => {
   function publishAgent(roomId: string, agent: GroupAgent): void {
     const detail = protocol.value.roomDetails[roomId]
     if (!detail) return
-    const index = detail.agents.findIndex(item => item.id === agent.id)
-    const agents = [...detail.agents]
-    if (index < 0) agents.push(agent)
-    else agents[index] = agent
     protocol.value = {
       ...protocol.value,
-      roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, agents } },
+      roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, agents: convergeGroupAgents(detail.agents, agent) } },
     }
   }
 
@@ -483,18 +480,29 @@ export const useGroupsStore = defineStore('groups', () => {
   }
 
   async function updateAgent(roomId: string, agentId: string, input: Partial<Omit<AgentSeed, 'profile'>> & { enabled?: boolean }): Promise<GroupAgent> {
+    const disabledHost = input.enabled === false
+      && protocol.value.roomDetails[roomId]?.agents.some(agent => agent.id === agentId && agent.isHost) === true
     const agent = await updateAgentApi(roomId, agentId, input)
     publishAgent(roomId, agent)
+    if (disabledHost && selectedRoomId.value === roomId) {
+      await loadRoomSnapshot(roomId, generation, selectionGeneration, selectedTopicId.value)
+      await saveCache()
+    }
     return agent
   }
 
   async function removeAgent(roomId: string, agentId: string): Promise<void> {
+    const removedHost = protocol.value.roomDetails[roomId]?.agents.some(agent => agent.id === agentId && agent.isHost) === true
     await removeAgentApi(roomId, agentId)
     const detail = protocol.value.roomDetails[roomId]
     if (detail) protocol.value = {
       ...protocol.value,
       roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, agents: detail.agents.filter(agent => agent.id !== agentId) } },
     }
+    if (removedHost && selectedRoomId.value === roomId) {
+      await loadRoomSnapshot(roomId, generation, selectionGeneration, selectedTopicId.value)
+      await saveCache()
+    }
   }
 
   async function sendMessage(content: string, mentionAgentIds: string[] = [], files: File[] = []): Promise<void> {
@@ -654,7 +662,7 @@ export const useGroupsStore = defineStore('groups', () => {
   }
 
   return {
-    availability, capabilities, topicProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
+    availability, capabilities, topicProtocol, hostProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
     messages, agents, pendingInteractions,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, addAgent, updateAgent,

+ 10 - 1
src/client/utils/groupProtocol.ts

@@ -36,6 +36,15 @@ function upsert<T extends { id: string }>(items: T[], incoming: T): T[] {
   return result
 }
 
+export function convergeGroupAgents(items: GroupAgent[], incoming: GroupAgent): GroupAgent[] {
+  const current = items.find(agent => agent.id === incoming.id)
+  if (current && incoming.updatedAt < current.updatedAt) return items
+  const converged = incoming.isHost
+    ? items.map(agent => agent.id === incoming.id || !agent.isHost ? agent : { ...agent, isHost: false })
+    : items
+  return upsert(converged, incoming)
+}
+
 export function upsertGroupTopic(items: GroupTopicSummary[], incoming: GroupTopicSummary): GroupTopicSummary[] {
   return upsert(items, incoming).sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id))
 }
@@ -84,7 +93,7 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
     case 'agent.updated': {
       const agent = normalizeGroupAgent(payload)
       const detail = next.roomDetails[agent.roomId || roomId]
-      if (detail) next.roomDetails[detail.id] = { ...detail, agents: upsert(detail.agents, agent) }
+      if (detail) next.roomDetails[detail.id] = { ...detail, agents: convergeGroupAgents(detail.agents, agent) }
       break
     }
     case 'agent.deleted': {

+ 1 - 0
src/client/utils/normalize.ts

@@ -352,6 +352,7 @@ export function normalizeGroupAgent(value: unknown): GroupAgent {
     description: string(source.description), storedSessionId: string(pick(source, 'storedSessionId', 'stored_session_id')) || null,
     lastContextMessageSeq: number(pick(source, 'lastContextMessageSeq', 'last_context_message_seq')),
     enabled: bool(source.enabled, true), replyWithoutMention: bool(pick(source, 'replyWithoutMention', 'reply_without_mention')),
+    isHost: bool(pick(source, 'isHost', 'is_host')),
     model: string(source.model) || null, provider: string(source.provider) || null,
     reasoningEffort: string(pick(source, 'reasoningEffort', 'reasoning_effort')) || null,
     fastMode: rawFastMode == null ? null : bool(rawFastMode),

+ 58 - 13
src/client/views/GroupsView.vue

@@ -44,6 +44,7 @@ const modelOptionsLoading = ref<Record<string, boolean>>({})
 const modelOptionsError = ref<Record<string, string>>({})
 const agentUpdateBusy = ref<Record<string, boolean>>({})
 const agentUpdateError = ref<Record<string, string>>({})
+const managerError = ref('')
 const roomActionMenu = ref<{ roomId: string; x: number; y: number } | null>(null)
 const expandedRoomIds = ref(new Set<string>())
 
@@ -90,6 +91,7 @@ const messages = computed(() => groups.messages.map(groupMessageToUi))
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const agents = computed(() => groups.agents.map(agentToUi))
+const hostAgent = computed(() => groups.hostProtocol ? groups.agents.find(agent => agent.isHost) : undefined)
 const connected = computed(() => ['connected', 'ready'].includes(groups.connectionState))
 const activeInteraction = computed(() => groupInteraction(groups.pendingInteractions[0]))
 const room = computed(() => groups.selectedRoom ? roomToUi(groups.selectedRoom) : null)
@@ -101,8 +103,18 @@ const reference = computed<ComposerReference | null>(() => quoted.value ? { id:
 const mentionNames = computed(() => ['所有人', ...groups.agents.map(agent => agent.displayName || agent.profile)])
 const mentionOptions = computed<ComposerOption[]>(() => [
   { id: 'all', label: '所有人', detail: '通知房间内全部 Agent' },
-  ...groups.agents.map(agent => ({ id: agent.id, label: agent.displayName || agent.profile, detail: agent.profile, disabled: !agent.enabled })),
+  ...groups.agents.map(agent => ({
+    id: agent.id,
+    label: agent.displayName || agent.profile,
+    detail: groups.hostProtocol && agent.isHost ? `主持人 · ${agent.profile}` : agent.profile,
+    disabled: !agent.enabled,
+  })),
 ])
+const roomSubtitle = computed(() => {
+  if (!groups.selectedRoom) return '选择或新建一个群聊'
+  const host = hostAgent.value ? `主持人 ${hostAgent.value.displayName || hostAgent.value.profile} · ` : ''
+  return `${groups.selectedRoom.name} · ${host}${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复`
+})
 const activeAgentIds = computed(() => {
   if (!groups.topicProtocol) {
     return new Set(groups.agents.filter(agent => ['queued', 'running'].includes(agent.status)).map(agent => agent.id))
@@ -247,10 +259,16 @@ async function startTopicFromRoomAction() {
   } catch { /* store publishes the error */ }
 }
 
-async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number }) {
+async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number; hostProfile?: string }) {
+  const hostProfile = payload.hostProfile || payload.profiles[0]
   const detail = await groups.createRoom({
     name: payload.name,
-    agents: payload.profiles.map(profile => ({ profile, displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile, replyWithoutMention: payload.autoReply })),
+    agents: payload.profiles.map(profile => ({
+      profile,
+      displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile,
+      replyWithoutMention: payload.autoReply,
+      ...(groups.hostProtocol ? { isHost: profile === hostProfile } : {}),
+    })),
     maxReplyRounds: payload.replyRounds,
   })
   createOpen.value = false
@@ -273,11 +291,16 @@ async function updateRoom(patch: { name?: string; replyRounds?: number }) {
 
 async function addAgent(profile: string) {
   if (!groups.selectedRoom) return
-  await groups.addAgent(groups.selectedRoom.id, { profile, displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile, replyWithoutMention: true })
+  await groups.addAgent(groups.selectedRoom.id, {
+    profile,
+    displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile,
+    replyWithoutMention: true,
+    ...(groups.hostProtocol ? { isHost: false } : {}),
+  })
 }
 
 type AgentSettingsPatch = Partial<Pick<GroupAgent,
-  'displayName' | 'description' | 'enabled' | 'replyWithoutMention' | 'model' | 'provider' | 'reasoningEffort' | 'fastMode'>>
+  'displayName' | 'description' | 'enabled' | 'replyWithoutMention' | 'isHost' | 'model' | 'provider' | 'reasoningEffort' | 'fastMode'>>
 
 async function loadAgentModels(profile: string) {
   if (modelOptionsLoading.value[profile] || Object.prototype.hasOwnProperty.call(modelOptionsByProfile.value, profile)) return
@@ -294,16 +317,31 @@ async function loadAgentModels(profile: string) {
 
 async function updateAgent(id: string, patch: AgentSettingsPatch) {
   if (!groups.selectedRoom) return
+  managerError.value = ''
   agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: true }
   agentUpdateError.value = { ...agentUpdateError.value, [id]: '' }
   try { await groups.updateAgent(groups.selectedRoom.id, id, patch) }
-  catch (cause) { agentUpdateError.value = { ...agentUpdateError.value, [id]: cause instanceof Error ? cause.message : 'Agent 设置保存失败' } }
+  catch (cause) {
+    const message = cause instanceof Error ? cause.message : 'Agent 设置保存失败'
+    agentUpdateError.value = { ...agentUpdateError.value, [id]: message }
+    managerError.value = message
+  }
+  finally { agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: false } }
+}
+
+async function removeAgent(id: string) {
+  if (!groups.selectedRoom) return
+  managerError.value = ''
+  agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: true }
+  try { await groups.removeAgent(groups.selectedRoom.id, id) }
+  catch (cause) { managerError.value = cause instanceof Error ? cause.message : '移除 Agent 失败' }
   finally { agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: false } }
 }
 
 function clearAgentError(id: string) {
   if (!agentUpdateError.value[id]) return
   agentUpdateError.value = { ...agentUpdateError.value, [id]: '' }
+  managerError.value = ''
 }
 
 async function archiveRoom() {
@@ -383,7 +421,11 @@ watch(() => [route.params.roomId, route.params.topicId] as const, async ([roomVa
     catch { if (groups.selectedRoomId && route.fullPath !== groupRoute()) await router.replace(groupRoute()) }
   }
 })
-watch(() => groups.selectedRoomId, roomId => { if (roomId) expandRoom(roomId) })
+watch(() => groups.selectedRoomId, roomId => {
+  if (roomId) expandRoom(roomId)
+  managerError.value = ''
+  agentUpdateError.value = {}
+})
 watch(() => groups.topicProtocol, supported => { if (supported && groups.selectedRoomId) expandRoom(groups.selectedRoomId) })
 watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThinking(profile) })
 </script>
@@ -419,7 +461,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
       <MessageTimeline
         :messages="messages"
         :title="groups.topicProtocol ? (groups.selectedTopic?.title || '新话题') : (groups.selectedRoom?.name || '群聊')"
-        :subtitle="groups.selectedRoom ? `${groups.selectedRoom.name} · ${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复` : '选择或新建一个群聊'"
+        :subtitle="roomSubtitle"
         :loading="groups.isLoading"
         :has-older="groups.hasMoreBefore"
         :connected="connected"
@@ -438,7 +480,8 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
       >
         <template #header-actions>
           <div class="group-header-actions">
-            <div class="group-avatars" aria-label="群聊成员"><span v-for="agent in agents.slice(0, 4)" :key="agent.id" :title="agent.name">{{ agent.name.slice(0, 1).toUpperCase() }}</span><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
+            <span v-if="hostAgent" class="group-host-chip" :title="`用户未明确 @ 时由 ${hostAgent.displayName || hostAgent.profile} 负责回应`">主持人 {{ hostAgent.displayName || hostAgent.profile }}</span>
+            <div class="group-avatars" aria-label="群聊成员"><span v-for="agent in agents.slice(0, 4)" :key="agent.id" :title="agent.isHost ? `${agent.name} · 主持人` : agent.name" :class="{ host: agent.isHost }">{{ agent.name.slice(0, 1).toUpperCase() }}</span><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
             <button class="icon-button" type="button" title="管理群聊" aria-label="管理群聊" :disabled="!groups.selectedRoom" @click="managerOpen = true"><AppIcon name="dots" /></button>
           </div>
         </template>
@@ -469,18 +512,20 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         v-if="room && groups.selectedRoom"
         :room="room"
         :agents="groups.agents"
+        :host-enabled="groups.hostProtocol"
         :available-profiles="availableProfiles"
         :busy="managerBusy"
         :model-options-by-profile="modelOptionsByProfile"
         :model-options-loading="modelOptionsLoading"
         :model-options-error="modelOptionsError"
         :agent-update-error="agentUpdateError"
+        :manager-error="managerError"
         @update-room="updateRoom"
         @add-agent="addAgent"
         @load-models="loadAgentModels"
         @clear-agent-error="clearAgentError"
         @update-agent="updateAgent"
-        @remove-agent="groups.removeAgent(groups.selectedRoom.id, $event)"
+        @remove-agent="removeAgent"
         @interrupt-agent="groups.interruptAgent($event, groups.selectedRoom.id)"
         @archive-room="archiveRoom"
       />
@@ -488,7 +533,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
   </WorkspaceView>
 
   <FloatingResourceSearch section="groups" label="搜索群聊" :items="roomSidebarItems" @select="selectRoom" />
-  <CreateGroupDialog :open="createOpen" :profiles="profiles" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
+  <CreateGroupDialog :open="createOpen" :profiles="profiles" :host-enabled="groups.hostProtocol" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
   <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
   <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />
 
@@ -510,8 +555,8 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
 .sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
 .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
 .sidebar-primary-action:disabled { cursor: not-allowed; opacity: .35; }
-.group-header-actions { display: flex; align-items: center; gap: 8px; }.group-avatars { display: flex; align-items: center; }.group-avatars span, .group-avatars em { display: grid; width: 24px; height: 24px; margin-left: -5px; place-items: center; border: 2px solid var(--canvas); border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; font-style: normal; font-weight: 650; }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
+.group-header-actions { display: flex; align-items: center; gap: 8px; }.group-host-chip { max-width: 150px; overflow: hidden; padding: 4px 7px; border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--accent); font-size: 9px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }.group-avatars { display: flex; align-items: center; }.group-avatars span, .group-avatars em { display: grid; width: 24px; height: 24px; margin-left: -5px; place-items: center; border: 2px solid var(--canvas); border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; font-style: normal; font-weight: 650; }.group-avatars span.host { box-shadow: 0 0 0 1px var(--accent); }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
 .group-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
-@media (max-width: 480px) { .group-avatars { display: none; } }
+@media (max-width: 480px) { .group-avatars, .group-host-chip { display: none; } }
 @media (prefers-reduced-motion: reduce) { .sidebar-primary-action, .group-menu-enter-active, .group-menu-leave-active { transition: none; } }
 </style>

+ 2 - 1
src/shared/types.ts

@@ -241,7 +241,7 @@ export interface GroupLimits {
 }
 
 export const MIN_SUPPORTED_GROUP_PROTOCOL_VERSION = 2
-export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 4
+export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 5
 export const SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL = `v${MIN_SUPPORTED_GROUP_PROTOCOL_VERSION}–v${MAX_SUPPORTED_GROUP_PROTOCOL_VERSION}`
 
 export function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -271,6 +271,7 @@ export interface GroupAgent {
   lastContextMessageSeq: number
   enabled: boolean
   replyWithoutMention: boolean
+  isHost: boolean
   model?: string | null
   provider?: string | null
   reasoningEffort?: string | null

+ 101 - 0
tests/client/groupHost.test.ts

@@ -0,0 +1,101 @@
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import { describe, expect, it } from 'vitest'
+import type { GroupAgent } from '@shared/types'
+import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
+import GroupManager from '@/components/groups/GroupManager.vue'
+
+function agent(id: string, profile: string, displayName: string, isHost: boolean, replyWithoutMention: boolean): GroupAgent {
+  return {
+    id, roomId: 'room-1', profile, displayName, description: `${displayName} 的职责`, storedSessionId: null,
+    lastContextMessageSeq: 0, enabled: true, replyWithoutMention, isHost,
+    model: null, provider: null, reasoningEffort: null, fastMode: null,
+    createdAt: 1, updatedAt: 1, status: 'idle',
+  }
+}
+
+describe('group host controls', () => {
+  it('keeps one independent host selection when creating a v5 room', async () => {
+    const wrapper = mount(CreateGroupDialog, {
+      attachTo: document.body,
+      props: { open: true, profiles: ['yaoyao', 'yaoer'], hostEnabled: true },
+      global: { stubs: { teleport: true } },
+    })
+    await nextTick()
+    await wrapper.get('input[placeholder="例如:产品评审"]').setValue('主持人验收')
+    const profileButtons = wrapper.findAll('.agent-picker > button')
+    await profileButtons[1]!.trigger('click')
+    const host = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
+    await host.setValue('yaoer')
+    await profileButtons[1]!.trigger('click')
+    expect(host.element.value).toBe('yaoyao')
+    await profileButtons[1]!.trigger('click')
+    await host.setValue('yaoer')
+    expect(wrapper.get<HTMLInputElement>('input[aria-label="所有成员无需 @ 也回复"]').element.checked).toBe(true)
+    await wrapper.get('.solid-button').trigger('click')
+
+    expect(wrapper.emitted('create')?.[0]?.[0]).toEqual({
+      name: '主持人验收', profiles: ['yaoyao', 'yaoer'], hostProfile: 'yaoer', autoReply: true, replyRounds: 3,
+    })
+    wrapper.unmount()
+  })
+
+  it('keeps the legacy v4 create UI and payload free of host fields', async () => {
+    const wrapper = mount(CreateGroupDialog, {
+      attachTo: document.body,
+      props: { open: true, profiles: ['yaoyao'], hostEnabled: false },
+      global: { stubs: { teleport: true } },
+    })
+    await nextTick()
+    expect(wrapper.find('select[aria-label="主持人"]').exists()).toBe(false)
+    expect(wrapper.find('input[aria-label="启用自动回复"]').exists()).toBe(true)
+    await wrapper.get('input[placeholder="例如:产品评审"]').setValue('v4 房间')
+    await wrapper.get('.solid-button').trigger('click')
+    expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('hostProfile')
+    wrapper.unmount()
+  })
+
+  it('emits one host promotion and explains the independent auto-reply setting', async () => {
+    const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
+    const second = agent('agent-2', 'yaoer', '瑶儿', false, false)
+    const wrapper = mount(GroupManager, {
+      attachTo: document.body,
+      props: {
+        room: { id: 'room-1', name: '群聊', memberIds: [first.id, second.id], replyRounds: 3 },
+        agents: [first, second],
+        hostEnabled: true,
+      },
+    })
+    const selector = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
+    expect(selector.element.value).toBe('agent-1')
+    expect(wrapper.findAll('.host-badge')).toHaveLength(1)
+    await selector.setValue('agent-2')
+    expect(wrapper.emitted('updateAgent')?.[0]).toEqual(['agent-2', { isHost: true }])
+
+    await wrapper.setProps({ agents: [{ ...first, isHost: false }, { ...second, isHost: true }] })
+    await wrapper.get('button[aria-label="设置瑶儿"]').trigger('click')
+    await nextTick()
+    const dialog = document.querySelector('[role="dialog"][aria-label="瑶儿 Agent 设置"]')
+    expect(dialog?.textContent).toContain('主持人始终处理用户无 @ 消息')
+    expect(dialog?.querySelector('input[aria-label="无需 @ 也回复"]')).not.toBeNull()
+    wrapper.unmount()
+  })
+
+  it('blocks removing a host when no enabled replacement exists and shows manager errors', () => {
+    const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
+    const second = { ...agent('agent-2', 'yaoer', '瑶儿', false, false), enabled: false }
+    const wrapper = mount(GroupManager, {
+      props: {
+        room: { id: 'room-1', name: '群聊', memberIds: [first.id, second.id], replyRounds: 3 },
+        agents: [first, second],
+        hostEnabled: true,
+        managerError: '需要另一位已启用成员',
+      },
+    })
+
+    const removeHost = wrapper.get<HTMLButtonElement>('button[aria-label="移除夭夭"]')
+    expect(removeHost.element.disabled).toBe(true)
+    expect(removeHost.attributes('title')).toContain('已启用成员')
+    expect(wrapper.get('[role="alert"]').text()).toBe('需要另一位已启用成员')
+  })
+})

+ 65 - 2
tests/client/groupProtocol.test.ts

@@ -1,8 +1,12 @@
 import { describe, expect, it } from 'vitest'
-import type { GroupMessage, GroupSocketEnvelope } from '@shared/types'
 import {
-  applyGroupEnvelope, classifyGroupEnvelope, type GroupProtocolState, upsertGroupMessage,
+  isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL,
+  type GroupAgent, type GroupMessage, type GroupSocketEnvelope,
+} from '@shared/types'
+import {
+  applyGroupEnvelope, classifyGroupEnvelope, convergeGroupAgents, type GroupProtocolState, upsertGroupMessage,
 } from '@/utils/groupProtocol'
+import { normalizeGroupAgent } from '@/utils/normalize'
 
 const epoch = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
 
@@ -27,6 +31,15 @@ function initial(): GroupProtocolState {
   }
 }
 
+function groupAgent(id: string, displayName: string, isHost: boolean): GroupAgent {
+  return {
+    id, roomId: 'room-1', profile: displayName, displayName, description: '', storedSessionId: null,
+    lastContextMessageSeq: 0, enabled: true, replyWithoutMention: false, isHost,
+    model: null, provider: null, reasoningEffort: null, fastMode: null,
+    createdAt: 1, updatedAt: 1, status: 'idle',
+  }
+}
+
 describe('group protocol continuity', () => {
   it('accepts only exact ready and next cursor, ignoring duplicates', () => {
     expect(classifyGroupEnvelope(epoch, 8, { type: 'group.ready', epoch, cursor: 8 })).toBe('ready')
@@ -93,4 +106,54 @@ describe('group protocol continuity', () => {
     expect(reduced.messagesByTopic['topic-1']).toEqual([expect.objectContaining({ id: 'server-topic', topicId: 'topic-1' })])
     expect(reduced.messagesByRoom['room-1']).toEqual([])
   })
+
+  it('converges immediately to one host when agent.updated promotes a new host', () => {
+    const state = initial()
+    const first = groupAgent('agent-1', '夭夭', true)
+    const second = groupAgent('agent-2', '瑶儿', false)
+    state.roomDetails['room-1'] = {
+      id: 'room-1', name: '群聊', cwd: '', createdAt: 1, updatedAt: 1, archived: false,
+      maxReplyRounds: 3, agents: [first, second], runs: [], pendingInteractions: [], latestCursor: 8,
+    }
+
+    const promoted = applyGroupEnvelope(state, {
+      type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'agent.updated',
+      payload: { ...second, isHost: true, updatedAt: 2 },
+    })
+    expect(promoted.roomDetails['room-1'].agents.map(agent => [agent.id, agent.isHost])).toEqual([
+      ['agent-1', false], ['agent-2', true],
+    ])
+
+    const demoted = applyGroupEnvelope(promoted, {
+      type: 'group.event', epoch, cursor: 10, roomId: 'room-1', event: 'agent.updated',
+      payload: { ...first, isHost: false, updatedAt: 2 },
+    })
+    expect(demoted.roomDetails['room-1'].agents.filter(agent => agent.isHost).map(agent => agent.id)).toEqual(['agent-2'])
+  })
+
+  it('ignores an older REST host response after a newer host event', () => {
+    const state = initial()
+    const former = groupAgent('agent-1', '夭夭', false)
+    former.updatedAt = 3
+    const current = groupAgent('agent-2', '瑶儿', true)
+    current.updatedAt = 3
+    state.roomDetails['room-1'] = {
+      id: 'room-1', name: '群聊', cwd: '', createdAt: 1, updatedAt: 1, archived: false,
+      maxReplyRounds: 3, agents: [former, current], runs: [], pendingInteractions: [], latestCursor: 8,
+    }
+
+    const stale = { ...former, isHost: true, updatedAt: 2 }
+    state.roomDetails['room-1'].agents = convergeGroupAgents(state.roomDetails['room-1'].agents, stale)
+
+    expect(state.roomDetails['room-1'].agents.filter(agent => agent.isHost).map(agent => agent.id)).toEqual(['agent-2'])
+    expect(state.roomDetails['room-1'].agents.find(agent => agent.id === 'agent-1')?.updatedAt).toBe(3)
+  })
+
+  it('normalizes v5 host fields while keeping v4 agents non-host by default', () => {
+    expect(normalizeGroupAgent({ id: 'legacy', room_id: 'room-1', profile: 'legacy' }).isHost).toBe(false)
+    expect(normalizeGroupAgent({ id: 'host', room_id: 'room-1', profile: 'host', is_host: true }).isHost).toBe(true)
+    expect(isSupportedGroupProtocolVersion(5)).toBe(true)
+    expect(isSupportedGroupProtocolVersion(6)).toBe(false)
+    expect(SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL).toBe('v2–v5')
+  })
 })

+ 29 - 3
tests/e2e/workspace.spec.ts

@@ -118,7 +118,33 @@ test('uses a floating search dialog for group rooms', async ({ page }) => {
   await expect(searchDialog).toBeHidden()
 })
 
-test('edits every protocol v4 Agent setting with one inspector close control', async ({ page }) => {
+test('selects one protocol v5 host independently from no-mention replies', async ({ page }) => {
+  await page.getByRole('button', { name: '群聊' }).click()
+  await expect(page.locator('.group-host-chip')).toHaveText('主持人 夭夭')
+
+  await page.getByRole('button', { name: '新建群聊' }).click()
+  const createDialog = page.getByRole('dialog', { name: '新建群聊' })
+  await createDialog.getByRole('button', { name: /yaoer/ }).click()
+  await createDialog.getByLabel('主持人').selectOption('yaoer')
+  await expect(createDialog.getByLabel('所有成员无需 @ 也回复')).toBeChecked()
+  await createDialog.getByRole('button', { name: '关闭' }).click()
+
+  await page.getByRole('button', { name: '管理群聊' }).click()
+  const manager = page.locator('.group-manager')
+  await expect(manager.getByLabel('主持人')).toHaveValue('33333333-3333-4333-8333-333333333333')
+  const promoteRequest = page.waitForRequest(request => request.method() === 'PATCH' && request.url().endsWith('/agents/34343434-3434-4434-8434-343434343434'))
+  await manager.getByLabel('主持人').selectOption({ label: '瑶儿' })
+  expect((await promoteRequest).postDataJSON()).toMatchObject({ isHost: true })
+  await expect(manager.locator('article').filter({ hasText: '瑶儿' }).getByText('主持人', { exact: true })).toBeVisible()
+  await expect(page.locator('.group-host-chip')).toHaveText('主持人 瑶儿')
+
+  const restoreRequest = page.waitForRequest(request => request.method() === 'PATCH' && request.url().endsWith('/agents/33333333-3333-4333-8333-333333333333'))
+  await manager.getByLabel('主持人').selectOption({ label: '夭夭' })
+  expect((await restoreRequest).postDataJSON()).toMatchObject({ isHost: true })
+  await expect(page.locator('.group-host-chip')).toHaveText('主持人 夭夭')
+})
+
+test('edits every protocol v5 Agent setting with one inspector close control', async ({ page }) => {
   await page.getByRole('button', { name: '群聊' }).click()
   await expect(page.getByRole('heading', { name: '设计验收' })).toBeVisible()
   await page.getByRole('button', { name: '管理群聊' }).click()
@@ -149,7 +175,7 @@ test('edits every protocol v4 Agent setting with one inspector close control', a
   await editor.getByLabel('模型').selectOption(JSON.stringify(['openai', 'gpt-5.5']))
   await editor.getByLabel('推理强度').selectOption('xhigh')
   await editor.getByLabel('快速模式').selectOption('false')
-  await editor.getByLabel('自动回复').uncheck()
+  await editor.getByLabel('无需 @ 也回复').uncheck()
   const updateRequest = page.waitForRequest(request => request.method() === 'PATCH' && /\/api\/app\/groups\/rooms\/[^/]+\/agents\/[^/]+$/.test(new URL(request.url()).pathname))
   await editor.getByRole('button', { name: '保存 Agent 设置' }).click()
   expect((await updateRequest).postDataJSON()).toMatchObject({
@@ -160,7 +186,7 @@ test('edits every protocol v4 Agent setting with one inspector close control', a
   await editor.getByLabel('模型').selectOption(JSON.stringify(['openai', 'gpt-5.6']))
   await editor.getByLabel('推理强度').selectOption('high')
   await editor.getByLabel('快速模式').selectOption('true')
-  await editor.getByLabel('自动回复').check()
+  await editor.getByLabel('无需 @ 也回复').check()
   const restoreResponse = page.waitForResponse(response => response.request().method() === 'PATCH' && /\/api\/app\/groups\/rooms\/[^/]+\/agents\/[^/]+$/.test(new URL(response.url()).pathname))
   await editor.getByRole('button', { name: '保存 Agent 设置' }).click()
   expect((await restoreResponse).status()).toBe(200)

+ 30 - 9
tests/fixtures/fake-hermes.mjs

@@ -5,6 +5,7 @@ const port = Number(process.env.FAKE_HERMES_PORT || 19119)
 const epoch = '11111111-1111-4111-8111-111111111111'
 const roomId = '22222222-2222-4222-8222-222222222222'
 const agentId = '33333333-3333-4333-8333-333333333333'
+const secondAgentId = '34343434-3434-4434-8434-343434343434'
 const topicId = '77777777-7777-4777-8777-777777777777'
 const releaseTopicId = '88888888-8888-4888-8888-888888888888'
 const now = () => Date.now() / 1000
@@ -68,7 +69,9 @@ const messages = [
   { id: 'message-delegation', role: 'user', content: '[ASYNC DELEGATION BATCH COMPLETE]\n后台子任务已经完成。', display_kind: 'async_delegation_complete', display_metadata: { task_count: 2, completed_count: 2, failed_count: 0, duration_seconds: 71 }, timestamp: now() - 140 },
   { id: 'message-system', role: 'user', content: '[System: The active model for this chat has changed to gpt-5.6-terra via provider openai.]', timestamp: now() - 130 },
 ]
-const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, model: 'gpt-5.6', provider: 'openai', reasoningEffort: 'high', fastMode: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
+const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, isHost: true, model: 'gpt-5.6', provider: 'openai', reasoningEffort: 'high', fastMode: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
+const secondGroupAgent = { id: secondAgentId, roomId, profile: 'yaoer', displayName: '瑶儿', description: '评审 Agent', enabled: true, replyWithoutMention: false, isHost: false, model: 'gpt-5.6', provider: 'openai', reasoningEffort: 'medium', fastMode: false, status: 'idle', createdAt: now() - 1900, updatedAt: now() }
+const groupAgents = [groupAgent, secondGroupAgent]
 const groupMessage = { seq: 1, id: '44444444-4444-4444-8444-444444444444', roomId, topicId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '大家好,检查一下群聊输入框。', status: 'completed', createdAt: now() - 120, updatedAt: now() - 120 }
 const groupAssistantMessage = { seq: 2, id: '55555555-5555-4555-8555-555555555555', roomId, topicId, senderKind: 'agent', senderId: agentId, senderName: '夭夭', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', status: 'completed', createdAt: now() - 100, updatedAt: now() - 100 }
 const releaseTopicMessage = { seq: 3, id: '99999999-9999-4999-8999-999999999999', roomId, topicId: releaseTopicId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '请核对发布话题的独立历史。', status: 'completed', createdAt: now() - 500, updatedAt: now() - 500 }
@@ -78,7 +81,7 @@ const groupTopics = [
   { id: releaseTopicId, roomId, title: '发布检查', preview: releaseTopicMessage.content, messageCount: 1, createdAt: now() - 500, updatedAt: now() - 500 },
 ]
 let nextGroupSeq = 4
-const room = { id: roomId, name: '设计与工程协作', cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: 1, maxReplyRounds: -1, lastMessage: groupAssistantMessage, unreadCount: 0 }
+const room = { id: roomId, name: '设计与工程协作', cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: groupAgents.length, maxReplyRounds: -1, lastMessage: groupAssistantMessage, unreadCount: 0 }
 const rpcRequests = []
 const previewPdf = Buffer.from('JVBERi0xLjQKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCAyMDAgMjAwXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDA1MyAwMDAwMCBuIAowMDAwMDAwMTA1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMTY2CiUlRU9G', 'base64')
 
@@ -139,17 +142,35 @@ const server = createServer(async (request, response) => {
     return session ? json(response, 200, session) : json(response, 404, { detail: 'Not found' })
   }
   if (url.pathname === '/api/session-unread') return json(response, 200, { items: { 'session-demo': 0 } })
-  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 4, journalEpoch: epoch, latestCursor: groupCursor, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'topic.updated', 'run.updated', 'agent.status'] })
+  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 5, journalEpoch: epoch, latestCursor: groupCursor, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'topic.updated', 'run.updated', 'agent.status', 'agent.updated'] })
   if (url.pathname === '/api/plugins/yaoyao/v1/rooms') return json(response, 200, { items: [room], nextCursor: null })
-  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: [groupAgent], runs: [], pendingInteractions: [], latestCursor: groupCursor })
-  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/agents/${agentId}` && request.method === 'PATCH') {
+  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: groupAgents, runs: [], pendingInteractions: [], latestCursor: groupCursor })
+  const agentPatchMatch = new RegExp(`^/api/plugins/yaoyao/v1/rooms/${roomId}/agents/([^/]+)$`).exec(url.pathname)
+  if (agentPatchMatch && request.method === 'PATCH') {
+    const target = groupAgents.find(agent => agent.id === decodeURIComponent(agentPatchMatch[1]))
+    if (!target) return json(response, 404, { detail: 'Agent not found' })
     const input = await body(request)
     if (input.displayName === '所有人') return json(response, 409, { detail: '成员名称不能使用“所有人”' })
-    for (const key of ['displayName', 'description', 'enabled', 'replyWithoutMention', 'model', 'provider', 'reasoningEffort', 'fastMode']) {
-      if (Object.prototype.hasOwnProperty.call(input, key)) groupAgent[key] = input[key]
+    const changed = []
+    if (input.isHost === true) {
+      for (const agent of groupAgents) {
+        if (agent.id !== target.id && agent.isHost) {
+          agent.isHost = false
+          agent.updatedAt = now()
+          changed.push(agent)
+        }
+      }
+    }
+    for (const key of ['displayName', 'description', 'enabled', 'replyWithoutMention', 'isHost', 'model', 'provider', 'reasoningEffort', 'fastMode']) {
+      if (Object.prototype.hasOwnProperty.call(input, key)) target[key] = input[key]
     }
-    groupAgent.updatedAt = now()
-    return json(response, 200, groupAgent)
+    target.updatedAt = now()
+    changed.push(target)
+    json(response, 200, target)
+    setTimeout(() => {
+      for (const agent of changed) broadcastGroup('agent.updated', agent)
+    }, 10)
+    return
   }
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/topics`) {
     return json(response, 200, { items: [...groupTopics].sort((a, b) => b.updatedAt - a.updatedAt), nextCursor: null })