From 5939da54b4f784f08795af5b9e43eb9de2cf2798 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Fri, 14 Aug 2026 23:33:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(plugin):=20=E8=BF=81=E7=A7=BB=20setup?= =?UTF-8?q?=20helper=20=E5=88=B0=20Commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/plugin-api-v2.yml | 28 ---- .github/workflows/plugin-api-v3.yml | 51 +++++++ plugin.py | 58 +++++++- tests/test_plugin.py | 211 ++++++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 32 deletions(-) delete mode 100644 .github/workflows/plugin-api-v2.yml create mode 100644 .github/workflows/plugin-api-v3.yml diff --git a/.github/workflows/plugin-api-v2.yml b/.github/workflows/plugin-api-v2.yml deleted file mode 100644 index 7c1876b..0000000 --- a/.github/workflows/plugin-api-v2.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: plugin-api-v2 - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -jobs: - contract: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: akashic-plugins/plugin-contracts - ref: 24543445c7b99ca63fcd90b5828f754a148b184c - path: .plugin-contracts - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Check Plugin API v2 - env: - PYTHONPATH: .plugin-contracts - run: python -m akashic_plugin_contracts check plugin.py diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml new file mode 100644 index 0000000..20aafd1 --- /dev/null +++ b/.github/workflows/plugin-api-v3.yml @@ -0,0 +1,51 @@ +name: plugin-api-v3 + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/plugin-contracts + ref: 4dd69dd621e029e51e99aa428443fa3a4ec1f6cf + path: .plugin-contracts + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check Plugin API v3 + env: + PYTHONPATH: .plugin-contracts + run: python -m akashic_plugin_contracts check plugin.py + + composition-parity: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: kachofugetsu09/akashic-agent + ref: 9cbd36c36fd4e9f9d407e2da7b53eedd9d35b145 + path: .akashic-core + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: .akashic-core/requirements.txt + - name: Install pinned Core dependencies + run: python -m pip install -r .akashic-core/requirements.txt pytest pytest-asyncio + - name: Compare v2 and v3 command receipts + env: + AKASHIC_AGENT_ROOT: .akashic-core + PYTHONPATH: .akashic-core + run: python -m pytest -q tests/ diff --git a/plugin.py b/plugin.py index 2ecc708..4ed490a 100644 --- a/plugin.py +++ b/plugin.py @@ -2,11 +2,18 @@ import os from pathlib import Path -from typing import cast +from typing import Protocol, cast from pydantic import BaseModel from agent.lifecycle.types import BeforeTurnCtx, TurnState +from agent.plugin_composition import ( + COMMANDS, + CommandDefinition, + CommandInvocation, + CommandResult, + Context, +) from agent.plugins import Plugin @@ -14,6 +21,48 @@ class SetupHelperConfig(BaseModel): qqbot_data_dir: str = "" +api_version = 3 +name = "setup_helper" +version = "1.0.0" +desc = "快速查询当前会话 chat_id,用于配置 proactive" +Config = SetupHelperConfig +inject = (COMMANDS,) + + +class _BeforeTurnFrame(Protocol): + input: TurnState + slots: dict[str, object] + + +async def apply(ctx: Context, config: SetupHelperConfig) -> None: + """Register the chat identity command against the Core command seam.""" + + # 1. Resolve plugin-owned presentation configuration once per generation. + qqbot_config_path = _qqbot_config_path(config) + + # 2. Core owns admission; this plugin owns the command behavior and text. + async def handle(invocation: CommandInvocation) -> CommandResult: + chat_id = invocation.chat_id or "(未知)" + return CommandResult( + "success", + _format_reply( + chat_id, + channel=invocation.channel, + qqbot_config_path=qqbot_config_path, + ), + ) + + await ctx.require(COMMANDS).register( + ctx, + CommandDefinition( + name="chatid", + description="查看我的 chat_id(配置 proactive 用)", + aliases=("myid",), + handler=handle, + ), + ) + + class ChatIdCommandModule: slot = "setup_helper.chatid" requires = ("before_turn.acquire_session", "session:session") @@ -23,9 +72,10 @@ def __init__(self, qqbot_config_path: Path | None) -> None: self._qqbot_config_path = qqbot_config_path async def run(self, frame: object) -> object: - if "session:ctx" in frame.slots: # type: ignore[attr-defined] + typed_frame = cast(_BeforeTurnFrame, frame) + if "session:ctx" in typed_frame.slots: return frame - state: TurnState = frame.input # type: ignore[attr-defined] + state = typed_frame.input if _normalize_command(state.msg.content) not in {"/chatid", "/myid"}: return frame chat_id = state.msg.chat_id or "(未知)" @@ -34,7 +84,7 @@ async def run(self, frame: object) -> object: channel=state.msg.channel, qqbot_config_path=self._qqbot_config_path, ) - frame.slots["session:ctx"] = _abort_ctx(state, reply) # type: ignore[attr-defined] + typed_frame.slots["session:ctx"] = _abort_ctx(state, reply) return frame diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 29c1bd2..7bfcf60 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,18 +1,49 @@ from __future__ import annotations +import shutil from datetime import datetime from pathlib import Path from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock, MagicMock import pytest +import plugin as plugin_module +from agent.context import ContextBuilder +from agent.core.passive_turn import ( + ContextStore, + PassiveTurnDeps, + PassiveTurnPipeline, + Reasoner, +) +from agent.looping.ports import SessionServices +from agent.plugin_composition import ( + COMMANDS, + CommandDescriptor, + CompositionRoot, + Context, + PluginCommands, + PluginRuntime, +) +from agent.plugins.composable import ComposablePlugin +from agent.plugins.manager import PluginManager +from agent.plugins.snapshot import bind_runtime_snapshot, reset_runtime_snapshot +from agent.tools.registry import ToolRegistry +from agent.turns.outbound import OutboundPort +from bus.event_bus import EventBus +from bus.events import InboundMessage, TurnDisposition from plugin import ( ChatIdCommandModule, SetupHelperConfig, + apply, + inject, _format_reply, _qqbot_config_path, ) +PLUGIN_ROOT = Path(__file__).parents[1] + @pytest.mark.asyncio async def test_chatid_command_aborts_turn() -> None: @@ -69,3 +100,183 @@ def test_missing_qqbot_data_dir_does_not_guess_marketplace( reply = _format_reply("c2c:abc", channel="qqbot", qqbot_config_path=None) assert "QQBOT_DATA_DIR" in reply assert "qqbot-github" not in reply + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("content", "channel", "chat_id"), + [ + ("/chatid", "telegram", "123"), + ("/myid", "mobile", "device-1"), + (" /CHATID@AkashicBot ignored ", "qqbot", "c2c:abc"), + ("/chatid", "telegram", ""), + ("/unknown", "telegram", "123"), + ], +) +async def test_v3_command_matches_v2_short_circuit( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + content: str, + channel: str, + chat_id: str, +) -> None: + monkeypatch.delenv("QQBOT_DATA_DIR", raising=False) + config = SetupHelperConfig(qqbot_data_dir=str(tmp_path / "qqbot")) + config_path = _qqbot_config_path(config) + + legacy_state = SimpleNamespace( + session_key=f"{channel}:{chat_id}", + msg=SimpleNamespace( + content=content, + channel=channel, + chat_id=chat_id, + timestamp=datetime.now(), + ), + ) + legacy_frame = SimpleNamespace( + input=legacy_state, + slots={"session:session": object()}, + ) + await ChatIdCommandModule(config_path).run(legacy_frame) + legacy_reply = ( + legacy_frame.slots["session:ctx"].abort_reply + if "session:ctx" in legacy_frame.slots + else None + ) + + _ = ComposablePlugin.from_module(plugin_module) + root = CompositionRoot("setup-helper-parity") + commands = PluginCommands() + _ = await root.context.provide(COMMANDS, commands) + + async def mount(ctx: Context) -> None: + await apply(ctx, config) + + _ = await root.mount( + mount, + name="setup_helper", + inject=inject, + runtime=PluginRuntime( + plugin_id="setup_helper", + plugin_dir=PLUGIN_ROOT, + data_dir=tmp_path / "plugin-data", + workspace=tmp_path / "workspace", + config=config, + ), + ) + registry = commands.freeze() + execution = await registry.execute( + content, + session_key=f"{channel}:{chat_id}", + channel=channel, + chat_id=chat_id, + sender="hua", + ) + candidate_reply = execution.result.text if execution is not None else None + + assert candidate_reply == legacy_reply + assert registry.descriptors == ( + CommandDescriptor( + name="chatid", + description="查看我的 chat_id(配置 proactive 用)", + ), + ) + assert root.receipt().writes == () + assert root.receipt().external_effects == () + + await root.dispose() + + assert root.receipt().services == () + assert root.receipt().effects == () + + +@pytest.mark.asyncio +async def test_v3_command_loads_and_bypasses_session_and_model( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("QQBOT_DATA_DIR", raising=False) + plugin_home = tmp_path / "plugins" + _ = shutil.copytree( + PLUGIN_ROOT, + plugin_home / "setup_helper", + ignore=shutil.ignore_patterns( + ".akashic-core", + ".git", + ".pytest_cache", + "__pycache__", + ), + ) + manager = PluginManager( + plugin_dirs=[plugin_home], + event_bus=EventBus(), + tool_registry=ToolRegistry(), + workspace=tmp_path / "workspace", + installed_cache_root=tmp_path / "plugin-home" / "cache", + ) + await manager.load_all() + + generation = manager.generation("setup_helper") + snapshot = manager.current_snapshot + assert generation is not None and snapshot is not None + assert isinstance(generation.instance, ComposablePlugin) + assert snapshot.command_registry is not None + assert manager.telegram_bot_commands == [ + ("chatid", "查看我的 chat_id(配置 proactive 用)") + ] + assert manager.mobile_bot_commands == [] + + session_manager = SimpleNamespace( + get_or_create=MagicMock(), + peek_next_message_id=MagicMock(), + append_messages=AsyncMock(), + ) + context_store = SimpleNamespace(prepare=AsyncMock()) + reasoner = SimpleNamespace(run_turn=AsyncMock()) + outbound_port = SimpleNamespace(dispatch=AsyncMock()) + pipeline = PassiveTurnPipeline( + PassiveTurnDeps( + session=cast( + SessionServices, + cast( + object, + SimpleNamespace(session_manager=session_manager, presence=None), + ), + ), + context_store=cast(ContextStore, cast(object, context_store)), + context=cast(ContextBuilder, cast(object, SimpleNamespace())), + tools=cast(ToolRegistry, cast(object, SimpleNamespace())), + reasoner=cast(Reasoner, cast(object, reasoner)), + outbound_port=cast(OutboundPort, cast(object, outbound_port)), + ) + ) + lease = manager.snapshot_store.lease() + token = bind_runtime_snapshot(lease) + try: + outbound = await pipeline.run( + InboundMessage( + channel="telegram", + sender="hua", + chat_id="123", + content="/myid@AkashicBot", + ), + "telegram:123", + ) + finally: + reset_runtime_snapshot(token) + await lease.release() + + assert outbound.content == _format_reply("123", channel="telegram") + assert outbound.turn_disposition is TurnDisposition.SHORT_CIRCUITED + session_manager.get_or_create.assert_not_called() + session_manager.peek_next_message_id.assert_not_called() + session_manager.append_messages.assert_not_awaited() + context_store.prepare.assert_not_awaited() + reasoner.run_turn.assert_not_awaited() + outbound_port.dispatch.assert_awaited_once() + + root = snapshot.composition_root + assert root is not None + await manager.terminate_all() + assert root.receipt().services == () + assert root.receipt().effects == () From 6e15fdcc9f93f9fc44e7f841e55e69727738debe Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Fri, 14 Aug 2026 23:44:53 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(plugin):=20=E5=9B=BA=E5=AE=9A=E6=97=A7?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E4=BC=9A=E8=AF=9D=E5=86=99=E5=85=A5=E5=B7=AE?= =?UTF-8?q?=E5=BC=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/plugin-api-v3.yml | 2 +- tests/test_plugin.py | 55 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 20aafd1..56489a5 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 9cbd36c36fd4e9f9d407e2da7b53eedd9d35b145 + ref: 8c45ab7873aac8c9eebcd22e9d6ae123f8dcf616 path: .akashic-core - uses: actions/setup-python@v5 with: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 7bfcf60..1f743d7 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -17,6 +17,14 @@ PassiveTurnPipeline, Reasoner, ) +from agent.core.types import ContextBundle +from agent.lifecycle.phase import Phase +from agent.lifecycle.phases.before_turn import ( + BeforeTurnFrame, + BeforeTurnModules, + default_before_turn_modules, +) +from agent.lifecycle.types import TurnState from agent.looping.ports import SessionServices from agent.plugin_composition import ( COMMANDS, @@ -41,6 +49,7 @@ _format_reply, _qqbot_config_path, ) +from session.manager import SessionManager PLUGIN_ROOT = Path(__file__).parents[1] @@ -190,6 +199,52 @@ async def mount(ctx: Context) -> None: assert root.receipt().effects == () +@pytest.mark.asyncio +async def test_v2_command_created_empty_session_metadata_before_short_circuit( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Pin the legacy incidental Session write that v3 intentionally removes.""" + + # 1. Run the legacy module in its real BeforeTurn ordering. + monkeypatch.delenv("QQBOT_DATA_DIR", raising=False) + workspace = tmp_path / "legacy-workspace" + session_manager = SessionManager(workspace) + context_store = SimpleNamespace( + prepare=AsyncMock(return_value=ContextBundle()), + ) + phase = Phase( + default_before_turn_modules( + EventBus(), + session_manager, + cast(ContextStore, cast(object, context_store)), + plugin_modules=cast( + BeforeTurnModules, + cast(object, [ChatIdCommandModule(None)]), + ), + ), + frame_factory=BeforeTurnFrame, + ) + state = TurnState( + msg=InboundMessage( + channel="telegram", + sender="hua", + chat_id="new-chat", + content="/chatid", + ), + session_key="telegram:new-chat", + dispatch_outbound=True, + ) + + result = await phase.run(state) + + # 2. A new manager can reopen the empty durable Session; Context stayed skipped. + reopened = SessionManager(workspace).get_existing("telegram:new-chat") + assert result.abort is True + assert reopened.messages == [] + context_store.prepare.assert_not_awaited() + + @pytest.mark.asyncio async def test_v3_command_loads_and_bypasses_session_and_model( monkeypatch: pytest.MonkeyPatch,