diff --git a/.gitignore b/.gitignore index 35c92ee93..44e4ed65b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,9 +39,11 @@ plugins/.venv/ # Tool caches .uv-cache/ +.astrbot/ # IDE files .idea/ .vscode/ *.iml uv.lock +/astrBot/ diff --git a/AGENTS.md b/AGENTS.md index c38eee0aa..d7fbf6ca4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,9 @@ - 2026-03-13: The maintained sample plugins now live under `test_plugin/old/` and `test_plugin/new/`. Runtime/integration tests should copy those real fixture directories instead of inlining synthetic plugin writers, otherwise the sample plugin tree and the exercised test path drift apart. - 2026-03-13: Real legacy plugins in the wild may still import `astrbot.api.*` instead of `astrbot_sdk.api.*`. Keeping only the `astrbot_sdk.api` compat surface is not enough for no-touch migration tests; preserve the `astrbot.api` alias package while old-plugin support remains a goal. - 2026-03-13: Real legacy `main.py` plugins may rely on package-relative imports like `from .src.tool import ...`. Loading legacy `main.py` as a bare file module breaks those imports; the loader must execute it under a synthetic package module so relative imports resolve. +- 2026-03-13: Real legacy plugins may also import `astrbot.core.utils.session_waiter` and use it as a first-class interactive flow primitive. A pure import stub is not enough; compat needs per-session follow-up message routing so awaited waiters can actually receive later messages. +- 2026-03-13: Real legacy `Star` plugins may call compat context helpers on `self` or `self.context` during `__init__()` and `initialize()`, including `self.put_kv_data(...)`, `self.get_kv_data(...)`, and `self.context.get_config()`. Keep proxy methods on `LegacyStar`, expose a safe `LegacyContext.get_config()`, and bind the shared legacy context before lifecycle hooks run. +- 2026-03-13: On Windows, `.gitignore` matching is case-insensitive. A broad entry like `astrBot/` will also ignore `src-new/astrbot/...`, which can silently hide real compat alias packages from `git status`. Keep that ignore anchored as `/astrBot/` if it is only meant for a root-level scratch checkout. # 开发命令 diff --git a/CLAUDE.md b/CLAUDE.md index c38eee0aa..d7fbf6ca4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,9 @@ - 2026-03-13: The maintained sample plugins now live under `test_plugin/old/` and `test_plugin/new/`. Runtime/integration tests should copy those real fixture directories instead of inlining synthetic plugin writers, otherwise the sample plugin tree and the exercised test path drift apart. - 2026-03-13: Real legacy plugins in the wild may still import `astrbot.api.*` instead of `astrbot_sdk.api.*`. Keeping only the `astrbot_sdk.api` compat surface is not enough for no-touch migration tests; preserve the `astrbot.api` alias package while old-plugin support remains a goal. - 2026-03-13: Real legacy `main.py` plugins may rely on package-relative imports like `from .src.tool import ...`. Loading legacy `main.py` as a bare file module breaks those imports; the loader must execute it under a synthetic package module so relative imports resolve. +- 2026-03-13: Real legacy plugins may also import `astrbot.core.utils.session_waiter` and use it as a first-class interactive flow primitive. A pure import stub is not enough; compat needs per-session follow-up message routing so awaited waiters can actually receive later messages. +- 2026-03-13: Real legacy `Star` plugins may call compat context helpers on `self` or `self.context` during `__init__()` and `initialize()`, including `self.put_kv_data(...)`, `self.get_kv_data(...)`, and `self.context.get_config()`. Keep proxy methods on `LegacyStar`, expose a safe `LegacyContext.get_config()`, and bind the shared legacy context before lifecycle hooks run. +- 2026-03-13: On Windows, `.gitignore` matching is case-insensitive. A broad entry like `astrBot/` will also ignore `src-new/astrbot/...`, which can silently hide real compat alias packages from `git status`. Keep that ignore anchored as `/astrBot/` if it is only meant for a root-level scratch checkout. # 开发命令 diff --git a/src-new/astrbot/__init__.py b/src-new/astrbot/__init__.py index c7d3cd796..d1bd13170 100644 --- a/src-new/astrbot/__init__.py +++ b/src-new/astrbot/__init__.py @@ -1,5 +1,5 @@ """旧版 ``astrbot`` 包名兼容入口。""" -from . import api +from . import api, core -__all__ = ["api"] +__all__ = ["api", "core"] diff --git a/src-new/astrbot/core/__init__.py b/src-new/astrbot/core/__init__.py new file mode 100644 index 000000000..91f88e9f9 --- /dev/null +++ b/src-new/astrbot/core/__init__.py @@ -0,0 +1,5 @@ +"""旧版 ``astrbot.core`` 导入路径兼容入口。""" + +from . import utils + +__all__ = ["utils"] diff --git a/src-new/astrbot/core/utils/__init__.py b/src-new/astrbot/core/utils/__init__.py new file mode 100644 index 000000000..6a15aa1d0 --- /dev/null +++ b/src-new/astrbot/core/utils/__init__.py @@ -0,0 +1,5 @@ +"""旧版 ``astrbot.core.utils`` 兼容入口。""" + +from .session_waiter import SessionController, session_waiter + +__all__ = ["SessionController", "session_waiter"] diff --git a/src-new/astrbot/core/utils/session_waiter.py b/src-new/astrbot/core/utils/session_waiter.py new file mode 100644 index 000000000..d4d921e89 --- /dev/null +++ b/src-new/astrbot/core/utils/session_waiter.py @@ -0,0 +1,5 @@ +"""旧版 ``astrbot.core.utils.session_waiter`` 导入路径兼容入口。""" + +from astrbot_sdk._session_waiter import SessionController, session_waiter + +__all__ = ["SessionController", "session_waiter"] diff --git a/src-new/astrbot_sdk/_legacy_api.py b/src-new/astrbot_sdk/_legacy_api.py index ceb352618..b1bf66f07 100644 --- a/src-new/astrbot_sdk/_legacy_api.py +++ b/src-new/astrbot_sdk/_legacy_api.py @@ -400,6 +400,13 @@ class LegacyContext: raise RuntimeError("LegacyContext 尚未绑定运行时 Context") return self._runtime_context + def get_config(self) -> dict[str, Any]: + runtime_context = self._runtime_context + if runtime_context is None: + return {} + config = getattr(runtime_context, "_astrbot_config", None) + return dict(config) if isinstance(config, dict) else {} + @staticmethod def _merge_llm_kwargs( *, @@ -592,6 +599,53 @@ class LegacyStar(Star): if config is not None: self.config = config + def _require_legacy_context(self) -> LegacyContext: + if self.context is None: + raise RuntimeError("LegacyStar 尚未绑定 compat Context") + return self.context + + async def put_kv_data(self, key: str, value: Any) -> None: + await self._require_legacy_context().put_kv_data(key, value) + + async def get_kv_data(self, key: str, default: Any = None) -> Any: + return await self._require_legacy_context().get_kv_data(key, default) + + async def delete_kv_data(self, key: str) -> None: + await self._require_legacy_context().delete_kv_data(key) + + async def send_message(self, session: str, message_chain: Any) -> None: + await self._require_legacy_context().send_message(session, message_chain) + + async def llm_generate( + self, + chat_provider_id: str, + *args: Any, + **kwargs: Any, + ) -> Any: + return await self._require_legacy_context().llm_generate( + chat_provider_id, + *args, + **kwargs, + ) + + async def tool_loop_agent( + self, + chat_provider_id: str, + *args: Any, + **kwargs: Any, + ) -> Any: + return await self._require_legacy_context().tool_loop_agent( + chat_provider_id, + *args, + **kwargs, + ) + + async def add_llm_tools(self, *tools: Any) -> None: + await self._require_legacy_context().add_llm_tools(*tools) + + def get_config(self) -> dict[str, Any]: + return self._require_legacy_context().get_config() + @classmethod def __astrbot_is_new_star__(cls) -> bool: return False diff --git a/src-new/astrbot_sdk/_session_waiter.py b/src-new/astrbot_sdk/_session_waiter.py new file mode 100644 index 000000000..25dce2283 --- /dev/null +++ b/src-new/astrbot_sdk/_session_waiter.py @@ -0,0 +1,130 @@ +"""旧版 ``session_waiter`` 的最小兼容实现。""" + +from __future__ import annotations + +import asyncio +import inspect +from dataclasses import dataclass +from typing import Any + + +@dataclass +class _SessionWaitState: + queue: asyncio.Queue[Any] + timeout: float | None + stopped: bool = False + + +class SessionController: + """兼容旧版交互式会话等待控制器。""" + + def __init__(self, state: _SessionWaitState) -> None: + self._state = state + + def keep( + self, + *, + timeout: float | None = None, + reset_timeout: bool = False, + ) -> None: + if timeout is not None and (reset_timeout or self._state.timeout is None): + self._state.timeout = timeout + self._state.stopped = False + + def stop(self) -> None: + self._state.stopped = True + + +class SessionWaiterManager: + """按会话路由后续消息到等待中的 compat 回调。""" + + def __init__(self) -> None: + self._waiters: dict[str, _SessionWaitState] = {} + + @staticmethod + def session_key(event: Any) -> str: + event = SessionWaiterManager._coerce_event(event) + unified = getattr(event, "unified_msg_origin", None) + if unified: + return str(unified) + session = getattr(event, "session_id", "") + return str(session) + + def register(self, event: Any, state: _SessionWaitState) -> str: + key = self.session_key(event) + if key in self._waiters: + raise RuntimeError(f"session_waiter 已存在活跃会话: {key}") + self._waiters[key] = state + return key + + def unregister(self, key: str, state: _SessionWaitState) -> None: + if self._waiters.get(key) is state: + self._waiters.pop(key, None) + + async def dispatch(self, event: Any) -> bool: + key = self.session_key(event) + state = self._waiters.get(key) + if state is None: + return False + await state.queue.put(self._coerce_event(event)) + return True + + @staticmethod + def _coerce_event(event: Any) -> Any: + from .api.event import AstrMessageEvent + + if isinstance(event, AstrMessageEvent): + return event + return AstrMessageEvent.from_message_event(event) + + +def session_waiter( + *, + timeout: float | None = None, + record_history_chains: bool | None = None, +): + """兼容旧版 ``@session_waiter`` 装饰器。 + + 当前实现只保留运行时等待下一条同会话消息的核心语义; + ``record_history_chains`` 仅为兼容旧签名而保留。 + """ + + del record_history_chains + + def decorator(func): + async def runner(event: Any, *args: Any, **kwargs: Any) -> None: + context = getattr(event, "_context", None) + manager = getattr(context, "_session_waiter_manager", None) + if manager is None: + raise RuntimeError("session_waiter 只能在插件运行时消息上下文中使用") + + state = _SessionWaitState(queue=asyncio.Queue(), timeout=timeout) + key = manager.register(event, state) + try: + while True: + try: + next_event = await asyncio.wait_for( + state.queue.get(), + timeout=state.timeout, + ) + except asyncio.TimeoutError as exc: + raise TimeoutError from exc + + controller = SessionController(state) + result = func(controller, next_event, *args, **kwargs) + if inspect.isawaitable(result): + await result + if state.stopped: + return + finally: + manager.unregister(key, state) + + runner.__name__ = getattr(func, "__name__", "session_waiter") + runner.__doc__ = getattr(func, "__doc__", None) + runner.__wrapped__ = func + return runner + + return decorator + + +__all__ = ["SessionController", "SessionWaiterManager", "session_waiter"] diff --git a/src-new/astrbot_sdk/api/event/filter.py b/src-new/astrbot_sdk/api/event/filter.py index e68381957..002c36199 100644 --- a/src-new/astrbot_sdk/api/event/filter.py +++ b/src-new/astrbot_sdk/api/event/filter.py @@ -452,6 +452,10 @@ def platform_adapter_type( class _FilterNamespace: + ADMIN = ADMIN + PermissionType = PermissionType + EventMessageType = EventMessageType + PlatformAdapterType = PlatformAdapterType command = staticmethod(command) regex = staticmethod(regex) permission = staticmethod(permission) diff --git a/src-new/astrbot_sdk/runtime/bootstrap.py b/src-new/astrbot_sdk/runtime/bootstrap.py index 40da7454a..4ce2357d8 100644 --- a/src-new/astrbot_sdk/runtime/bootstrap.py +++ b/src-new/astrbot_sdk/runtime/bootstrap.py @@ -645,6 +645,7 @@ class PluginWorkerRuntime: self._lifecycle_context = RuntimeContext( peer=self.peer, plugin_id=self.plugin.name ) + self._bind_legacy_runtime_contexts(self._lifecycle_context) self.peer.set_invoke_handler(self._handle_invoke) self.peer.set_cancel_handler(self._handle_cancel) @@ -717,6 +718,23 @@ class PluginWorkerRuntime: if inspect.isawaitable(result): await result + def _bind_legacy_runtime_contexts(self, runtime_context: RuntimeContext) -> None: + seen: set[int] = set() + for loaded in [ + *self.loaded_plugin.handlers, + *self.loaded_plugin.capabilities, + ]: + legacy_context = getattr(loaded, "legacy_context", None) + if legacy_context is None: + continue + marker = id(legacy_context) + if marker in seen: + continue + seen.add(marker) + bind_runtime_context = getattr(legacy_context, "bind_runtime_context", None) + if callable(bind_runtime_context): + bind_runtime_context(runtime_context) + @staticmethod def _resolve_lifecycle_hook(instance: Any, method_name: str): hook = getattr(instance, method_name, None) diff --git a/src-new/astrbot_sdk/runtime/handler_dispatcher.py b/src-new/astrbot_sdk/runtime/handler_dispatcher.py index 50142c297..04cefe156 100644 --- a/src-new/astrbot_sdk/runtime/handler_dispatcher.py +++ b/src-new/astrbot_sdk/runtime/handler_dispatcher.py @@ -72,6 +72,7 @@ import typing from collections.abc import AsyncIterator from typing import Any, get_type_hints +from .._session_waiter import SessionWaiterManager from ..context import CancelToken, Context from ..errors import AstrBotError from ..events import MessageEvent, PlainTextResult @@ -86,6 +87,7 @@ class HandlerDispatcher: self._peer = peer self._handlers = {item.descriptor.id: item for item in handlers} self._active: dict[str, tuple[asyncio.Task[Any], CancelToken]] = {} + self._session_waiters = SessionWaiterManager() async def invoke(self, message, cancel_token: CancelToken) -> dict[str, Any]: handler_id = str(message.input.get("handler_id", "")) @@ -96,8 +98,11 @@ class HandlerDispatcher: ctx = Context( peer=self._peer, plugin_id=self._plugin_id, cancel_token=cancel_token ) + ctx._session_waiter_manager = self._session_waiters event = MessageEvent.from_payload(message.input.get("event", {}), context=ctx) event.bind_reply_handler(self._create_reply_handler(ctx, event)) + if await self._session_waiters.dispatch(event): + return {} if loaded.legacy_context is not None: loaded.legacy_context.bind_runtime_context(ctx) diff --git a/tests_v4/test_api_event_filter.py b/tests_v4/test_api_event_filter.py index f5a1b96c7..5d08e59cb 100644 --- a/tests_v4/test_api_event_filter.py +++ b/tests_v4/test_api_event_filter.py @@ -198,6 +198,12 @@ class TestFilterNamespace: meta = get_handler_meta(admin_handler) assert meta.permissions.require_admin is True + def test_filter_namespace_exposes_legacy_enum_constants(self): + """filter namespace should carry the old enum/constant attributes.""" + assert filter.ADMIN == ADMIN + assert filter.PermissionType is PermissionType + assert filter.EventMessageType is EventMessageType + class TestCompatFilterComposition: """Tests for legacy filter composition helpers.""" diff --git a/tests_v4/test_api_legacy_context.py b/tests_v4/test_api_legacy_context.py index 1ec7d2834..3e90ee660 100644 --- a/tests_v4/test_api_legacy_context.py +++ b/tests_v4/test_api_legacy_context.py @@ -14,6 +14,7 @@ from astrbot_sdk._legacy_api import ( Context, LegacyContext, LegacyConversationManager, + LegacyStar, ) from astrbot_sdk.api.message import Comp, MessageChain from astrbot_sdk.star import Star @@ -49,6 +50,22 @@ class TestLegacyContext: assert legacy_ctx.require_runtime_context() is mock_ctx + def test_get_config_returns_empty_dict_without_runtime_context(self): + """get_config() should gracefully degrade before runtime binding.""" + legacy_ctx = LegacyContext("test_plugin") + + assert legacy_ctx.get_config() == {} + + def test_get_config_reads_runtime_context_config_dict(self): + """get_config() should expose the bound runtime config mapping.""" + mock_ctx = MagicMock() + mock_ctx._astrbot_config = {"admins_id": ["1001"]} + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx.bind_runtime_context(mock_ctx) + + assert legacy_ctx.get_config() == {"admins_id": ["1001"]} + def test_context_alias_is_legacy_context(self): """Context should be an alias for LegacyContext.""" assert Context is LegacyContext @@ -516,6 +533,38 @@ class TestCommandComponent: assert ctx.plugin_id == "my_plugin" +class TestLegacyStarDelegation: + """Tests for LegacyStar methods that proxy to LegacyContext.""" + + @pytest.mark.asyncio + async def test_put_kv_data_delegates_to_context(self): + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx.put_kv_data = AsyncMock() + star = LegacyStar(legacy_ctx) + + await star.put_kv_data("key", 1) + + legacy_ctx.put_kv_data.assert_awaited_once_with("key", 1) + + @pytest.mark.asyncio + async def test_get_kv_data_delegates_to_context(self): + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx.get_kv_data = AsyncMock(return_value=True) + star = LegacyStar(legacy_ctx) + + result = await star.get_kv_data("key", False) + + legacy_ctx.get_kv_data.assert_awaited_once_with("key", False) + assert result is True + + def test_get_config_delegates_to_context(self): + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx.get_config = MagicMock(return_value={"admins_id": ["42"]}) + star = LegacyStar(legacy_ctx) + + assert star.get_config() == {"admins_id": ["42"]} + + class TestMigrationDocUrl: """Tests for migration documentation URL.""" diff --git a/tests_v4/test_api_modules.py b/tests_v4/test_api_modules.py index 6e3ce388d..6516d3c0f 100644 --- a/tests_v4/test_api_modules.py +++ b/tests_v4/test_api_modules.py @@ -105,7 +105,9 @@ class TestApiEventModule: assert MessageSession is not None assert MessageType is not None - def test_astr_message_event_preserves_legacy_message_str_and_private_group_none(self): + def test_astr_message_event_preserves_legacy_message_str_and_private_group_none( + self, + ): """AstrMessageEvent should expose message_str and return None for missing group.""" from astrbot_sdk.api.event import AstrMessageEvent @@ -217,3 +219,13 @@ class TestAstrbotImportAlias: assert Star is not None assert callable(StarTools.get_data_dir) assert callable(register) + + def test_legacy_astrbot_core_session_waiter_exports(self): + """astrbot.core.utils.session_waiter should expose the compat waiter helpers.""" + from astrbot.core.utils.session_waiter import ( + SessionController, + session_waiter, + ) + + assert SessionController is not None + assert callable(session_waiter) diff --git a/tests_v4/test_bootstrap.py b/tests_v4/test_bootstrap.py index 8b5e751c8..c467ded3c 100644 --- a/tests_v4/test_bootstrap.py +++ b/tests_v4/test_bootstrap.py @@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest import yaml +from astrbot_sdk._legacy_api import LegacyContext from astrbot_sdk.context import CancelToken from astrbot_sdk.errors import AstrBotError from astrbot_sdk.protocol.descriptors import ( @@ -953,6 +954,42 @@ class DemoComponent(Star): assert called == ["initialize", "terminate"] + def test_bind_legacy_runtime_contexts_reuses_shared_context(self): + """legacy lifecycle helpers should see the worker runtime context.""" + with tempfile.TemporaryDirectory() as temp_dir: + plugin_dir = Path(temp_dir) + manifest_path = plugin_dir / "plugin.yaml" + requirements_path = plugin_dir / "requirements.txt" + + manifest_path.write_text( + yaml.dump( + { + "name": "test_plugin", + "runtime": {"python": "3.12"}, + "components": [], + } + ), + encoding="utf-8", + ) + requirements_path.write_text("", encoding="utf-8") + + runtime = PluginWorkerRuntime( + plugin_dir=plugin_dir, transport=MemoryTransport() + ) + legacy_context = LegacyContext("test_plugin") + runtime.loaded_plugin.handlers.append( + SimpleNamespace(legacy_context=legacy_context) + ) + runtime.loaded_plugin.capabilities.append( + SimpleNamespace(legacy_context=legacy_context) + ) + + runtime._bind_legacy_runtime_contexts(runtime._lifecycle_context) + + assert ( + legacy_context.require_runtime_context() is runtime._lifecycle_context + ) + class TestIntegrationWithTransportPair: """Integration tests using transport pairs.""" diff --git a/tests_v4/test_external_plugin_smoke.py b/tests_v4/test_external_plugin_smoke.py new file mode 100644 index 000000000..9a3c2c271 --- /dev/null +++ b/tests_v4/test_external_plugin_smoke.py @@ -0,0 +1,80 @@ +"""可选的外部插件兼容 smoke 测试。 + +默认不跑;手动验证真实外部插件时,设置 +``ASTRBOT_EXTERNAL_PLUGIN_REPO=https://...`` 即可启用。 +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import textwrap +from pathlib import Path + +import pytest + +from astrbot_sdk.runtime.loader import ( + PluginEnvironmentManager, + load_plugin_spec, +) + +EXTERNAL_PLUGIN_REPO_ENV = "ASTRBOT_EXTERNAL_PLUGIN_REPO" + + +@pytest.mark.skipif( + not os.getenv(EXTERNAL_PLUGIN_REPO_ENV), + reason=f"set {EXTERNAL_PLUGIN_REPO_ENV} to enable external plugin smoke tests", +) +def test_external_plugin_load_smoke(): + """按需 clone 外部插件仓库并验证其能在独立环境里完成加载。""" + repo_url = os.environ[EXTERNAL_PLUGIN_REPO_ENV] + project_root = Path(__file__).resolve().parent.parent + + with tempfile.TemporaryDirectory(prefix="astrbot-external-plugin-") as temp_dir: + clone_dir = Path(temp_dir) / "plugin" + subprocess.run( + ["git", "clone", "--depth", "1", repo_url, str(clone_dir)], + check=True, + cwd=project_root, + capture_output=True, + text=True, + ) + + spec = load_plugin_spec(clone_dir) + manager = PluginEnvironmentManager(project_root) + python_path = manager.prepare_environment(spec) + script = textwrap.dedent( + f""" + import sys + from pathlib import Path + + repo_root = Path({str(project_root)!r}) + plugin_dir = Path({str(clone_dir)!r}) + sys.path.insert(0, str((repo_root / "src-new").resolve())) + + from astrbot_sdk.runtime.loader import load_plugin, load_plugin_spec + + spec = load_plugin_spec(plugin_dir) + loaded = load_plugin(spec) + print("PLUGIN", loaded.plugin.name) + print("HANDLERS", len(loaded.handlers)) + print("CAPS", len(loaded.capabilities)) + """ + ) + env = os.environ.copy() + env["PYTHONPATH"] = str((project_root / "src-new").resolve()) + result = subprocess.run( + [str(python_path), "-c", script], + check=False, + cwd=project_root, + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, ( + f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}" + ) + assert "HANDLERS" in result.stdout + assert "PLUGIN" in result.stdout diff --git a/tests_v4/test_handler_dispatcher.py b/tests_v4/test_handler_dispatcher.py index 71b3f9c5a..d710a4602 100644 --- a/tests_v4/test_handler_dispatcher.py +++ b/tests_v4/test_handler_dispatcher.py @@ -10,6 +10,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from astrbot.core.utils.session_waiter import SessionController, session_waiter +from astrbot_sdk._legacy_api import LegacyContext from astrbot_sdk.api.event import AstrMessageEvent from astrbot_sdk.api.message import Comp, MessageChain from astrbot_sdk.context import CancelToken, Context @@ -429,6 +431,81 @@ class TestHandlerDispatcherInvoke: assert sent_messages == [{"session_id": "session-sync", "text": "fallback"}] + @pytest.mark.asyncio + async def test_invoke_routes_followup_message_to_session_waiter(self): + """compat session_waiter should capture the next message from the same session.""" + peer = MockPeer() + legacy_context = LegacyContext("test_plugin") + captured_replies = [] + + async def handler_func(event: AstrMessageEvent): + await event.send(MessageChain().message("请输入确认内容")) + + @session_waiter(timeout=0.2, record_history_chains=False) + async def waiter(controller: SessionController, ev: AstrMessageEvent): + captured_replies.append(ev.message_str) + await ev.send(MessageChain().message(f"收到:{ev.message_str}")) + controller.stop() + + await waiter(event) + + descriptor = HandlerDescriptor( + id="legacy.waiter", + trigger=CommandTrigger(command="ask"), + ) + handler = LoadedHandler( + descriptor=descriptor, + callable=handler_func, + owner=MagicMock(), + legacy_context=legacy_context, + ) + dispatcher = HandlerDispatcher( + plugin_id="test_plugin", + peer=peer, + handlers=[handler], + ) + + first_message = InvokeMessage( + id="msg_waiter_1", + capability="handler.invoke", + input={ + "handler_id": "legacy.waiter", + "event": { + "text": "ask", + "session_id": "session-waiter", + "user_id": "user-1", + "platform": "test", + }, + }, + ) + followup_message = InvokeMessage( + id="msg_waiter_2", + capability="handler.invoke", + input={ + "handler_id": "legacy.waiter", + "event": { + "text": "确认", + "session_id": "session-waiter", + "user_id": "user-1", + "platform": "test", + }, + }, + ) + + waiting_task = asyncio.create_task( + dispatcher.invoke(first_message, CancelToken()) + ) + await asyncio.sleep(0.05) + result = await dispatcher.invoke(followup_message, CancelToken()) + await waiting_task + + assert result == {} + assert captured_replies == ["确认"] + assert peer.sent_messages == [ + {"session_id": "session-waiter", "text": "请输入确认内容"}, + {"session_id": "session-waiter", "text": "收到:确认"}, + ] + class TestHandlerDispatcherCancel: """Tests for HandlerDispatcher.cancel method."""