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..392cdec --- /dev/null +++ b/.github/workflows/plugin-api-v3.yml @@ -0,0 +1,57 @@ +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: 3005f838bcd96e2cbc58616aede46e4f39df4523 + path: .akashic-core + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/citation + ref: b82453cd1eae71da8d25eb31aada30b01c659b54 + path: .citation + - 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: Verify Meme v3 behavior + env: + AKASHIC_AGENT_ROOT: .akashic-core + AKASHIC_CITATION_ROOT: .citation + PYTHONPATH: .akashic-core + run: python -m pytest -q tests/ diff --git a/README.md b/README.md index de70376..a1661a7 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,23 @@ | 接入方式 | 阶段 | |---|---| -| `prompt_render_modules()` | `prompt_render.emit` 之后——注入表情包目录说明 | -| `@on_after_reasoning()` | AfterReasoning GATE——解析 meme 标签,附加媒体 | +| v3 `PROMPT_RENDER_EVENT` | 注入表情包目录说明 | +| v3 `AFTER_REASONING_PREPROCESS_EVENT` | 解析 meme 标签,附加媒体 | +| `skill_roots = ("skills",)` | 声明管理 Skill | +| `dashboard_module = "dashboard.py"` | 声明 v3 Dashboard | +| `workspace_roots = ("memes",)` | 取得 Core 分配的表情包资产根 | + +插件通过模块命名导出声明静态贡献,通过 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator` 并注册接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。Core 只分配生命周期、依赖顺序与 workspace root;Meme 的目录结构、随机选图和 Dashboard 仍由插件拥有。 --- ## 运作逻辑 -### 1. 初始化(initialize) +### 1. 初始化 从工作区路径(`workspace/memes/`)加载 `manifest.json`,构建 `MemeCatalog` 和 `MemeDecorator` 实例。`MemeCatalog` 按需检测 manifest 的 mtime,变动时自动热重载,不需要重启。 -### 2. 注入 catalog(MemePromptModule) +### 2. 注入 catalog 每轮推理前,调用 `catalog.build_prompt_block()` 把启用的表情包类别(名称、描述、别名)拼成文本块,追加到系统 prompt 底部,告知 LLM 可以在回复中嵌入 `` 标签。如果 catalog 为空则跳过注入。 diff --git a/akashic.plugin.toml b/akashic.plugin.toml new file mode 100644 index 0000000..7f4da6f --- /dev/null +++ b/akashic.plugin.toml @@ -0,0 +1,5 @@ +schema_version = 1 +name = "meme" +version = "1.0.0" +api_version = 3 +entrypoint = "plugin.py" diff --git a/dashboard.py b/dashboard.py index 994ee52..fc996a0 100644 --- a/dashboard.py +++ b/dashboard.py @@ -1,17 +1,17 @@ from __future__ import annotations import os -from pathlib import Path from typing import Any from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse -from .plugin import _workspace +from agent.plugin_composition import DashboardContext from .runtime import MemeCatalog -def register(app: FastAPI, plugin_dir: Path, workspace: Path) -> None: - memes_dir = _workspace(plugin_dir, workspace) / "memes" + +def register(app: FastAPI, context: DashboardContext) -> None: + memes_dir = context.workspace_root("memes") catalog = MemeCatalog(memes_dir) @app.get("/api/dashboard/meme/categories") diff --git a/plugin.py b/plugin.py index cad2f69..fb579f1 100644 --- a/plugin.py +++ b/plugin.py @@ -1,89 +1,71 @@ from __future__ import annotations import re -from pathlib import Path -from typing import Any, cast +from agent.lifecycle.composition import ( + AFTER_REASONING_PREPROCESS_EVENT, + PROMPT_RENDER_EVENT, +) from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx -from agent.plugins import Plugin, on_after_reasoning +from agent.plugin_composition import Context, ServiceKey from agent.prompting import PromptSectionRender from .runtime import MemeCatalog, MemeDecorator -_CTX_SLOT = "prompt:ctx" _MEME_RE = re.compile( r"(?(?!`)", re.IGNORECASE, ) +CITATION_PROTOCOL_SERVICE = ServiceKey[object]("citation.protocol") + -class MemePromptModule: - slot = "meme.prompt" - requires = ("prompt_render.emit", "citation.prompt", _CTX_SLOT) - produces = (_CTX_SLOT,) - - def __init__(self, plugin: "MemePlugin") -> None: - self._plugin = plugin - - async def run(self, frame: Any) -> Any: - ctx = frame.slots.get(_CTX_SLOT) - if not isinstance(ctx, PromptRenderCtx): - return frame - block = self._plugin.catalog.build_prompt_block() - if not block: - return frame - ctx.system_sections_bottom.append( - PromptSectionRender( - name="memes", - content=f"# Memes\n\n{block}", - is_static=False, - ) +def append_meme_prompt(ctx: PromptRenderCtx, catalog: MemeCatalog) -> None: + block = catalog.build_prompt_block() + if not block: + return + ctx.system_sections_bottom.append( + PromptSectionRender( + name="memes", + content=f"# Memes\n\n{block}", + is_static=False, ) - return frame - - -class MemePlugin(Plugin): - api_version = 2 - @classmethod - def dashboard_module(cls) -> str | None: - return "dashboard.py" - - name = "meme" - version = "1.0.0" - - @classmethod - def skill_roots(cls) -> tuple[str, ...]: - return ("skills",) - _catalog: Any = None - _decorator: Any = None - - async def prepare(self) -> None: - memes_dir = _workspace(self.context.plugin_dir, self.context.workspace) / "memes" - self._catalog = MemeCatalog(memes_dir) - self._decorator = MemeDecorator(self._catalog) - - def prompt_render_modules(self) -> list[object]: - return [MemePromptModule(self)] - - @on_after_reasoning() - async def decorate_meme(self, ctx: AfterReasoningCtx) -> AfterReasoningCtx: - cleaned, tag = _extract_meme_tag(ctx.reply) - decorated = self.decorator.decorate(cleaned, meme_tag=tag) - ctx.reply = decorated.content - ctx.media.extend(decorated.media) - ctx.meme_tag = decorated.tag - return ctx - - @property - def catalog(self) -> Any: - if self._catalog is None: - raise RuntimeError("meme 插件尚未初始化") - return self._catalog - - @property - def decorator(self) -> Any: - if self._decorator is None: - raise RuntimeError("meme 插件尚未初始化") - return self._decorator + ) + + +def decorate_meme_ctx(ctx: AfterReasoningCtx, decorator: MemeDecorator) -> None: + cleaned, tag = _extract_meme_tag(ctx.reply) + decorated = decorator.decorate(cleaned, meme_tag=tag) + ctx.reply = decorated.content + ctx.media.extend(decorated.media) + ctx.meme_tag = decorated.tag + + +api_version = 3 +name = "meme" +version = "1.0.0" +inject: tuple[ServiceKey[object], ...] = (CITATION_PROTOCOL_SERVICE,) +skill_roots = ("skills",) +dashboard_module = "dashboard.py" +workspace_roots = ("memes",) + + +async def apply(ctx: Context, config: object) -> None: + """Build Meme domain objects and register their Core-hosted adapters.""" + + # 1. Domain state remains plugin-owned and reads the assigned workspace. + _ = config + catalog = MemeCatalog(ctx.workspace_root("memes")) + decorator = MemeDecorator(catalog) + + # 2. Lifecycle behavior is owned by reversible Fiber effects. + def prompt_listener(prompt: PromptRenderCtx) -> None: + append_meme_prompt(prompt, catalog) + + def answer_listener(answer: AfterReasoningCtx) -> None: + decorate_meme_ctx(answer, decorator) + + _ = await ctx.on(PROMPT_RENDER_EVENT, prompt_listener) + _ = await ctx.on(AFTER_REASONING_PREPROCESS_EVENT, answer_listener) def _extract_meme_tag(response: str) -> tuple[str, str | None]: @@ -94,9 +76,3 @@ def _extract_meme_tag(response: str) -> tuple[str, str | None]: cleaned = re.sub(r"[ \t]+\n", "\n", cleaned) cleaned = re.sub(r" {2,}", " ", cleaned) return cleaned.strip(), match.group(1).lower() - - -def _workspace(plugin_dir: Path, configured: Path | None) -> Path: - if configured is not None: - return configured - return cast(Path, plugin_dir.parent.parent) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index d4091a9..76fd844 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,17 +1,36 @@ from __future__ import annotations -import json +import importlib import importlib.util -from datetime import datetime, timezone -from types import SimpleNamespace +import json +import os from pathlib import Path +import shutil import sys +from datetime import datetime, timezone import pytest +from fastapi import FastAPI +from fastapi.routing import APIRoute from agent.core.response_parser import ResponseMetadata +from agent.lifecycle.composition import ( + AFTER_REASONING_CLEANUP_EVENT, + AFTER_REASONING_PREPROCESS_EVENT, + PROMPT_RENDER_EVENT, +) from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx -from agent.plugins.context import PluginContext, PluginKVStore +from agent.plugin_composition import ( + CompositionRoot, + Context, + DashboardContext, + PluginRuntime, +) +from agent.plugins.composable import ComposablePlugin +from agent.plugins.dashboard_host import DashboardBinding, PluginDashboardHost +from agent.plugins.manager import PluginManager +from agent.plugins.static_manifest import load_static_plugin_manifest +from bus.event_bus import EventBus from runtime import MemeCatalog, MemeDecorator @@ -30,9 +49,37 @@ def _load_meme_plugin_module(): return module +def _load_exact_citation_module(citation_root: Path): + path = citation_root / "plugin.py" + spec = importlib.util.spec_from_file_location( + "test_exact_citation_plugin", + path, + submodule_search_locations=[str(path.parent)], + ) + if spec is None or spec.loader is None: + raise ImportError(str(path)) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + _meme_plugin_module = _load_meme_plugin_module() -MemePlugin = _meme_plugin_module.MemePlugin -MemePromptModule = _meme_plugin_module.MemePromptModule +CITATION_PROTOCOL_SERVICE = _meme_plugin_module.CITATION_PROTOCOL_SERVICE +apply = _meme_plugin_module.apply +decorate_meme_ctx = _meme_plugin_module.decorate_meme_ctx +inject = _meme_plugin_module.inject + + +def _copy_ignore(): + return shutil.ignore_patterns( + ".akashic-core", + ".citation", + ".git", + ".plugin-contracts", + ".pytest_cache", + "__pycache__", + ) def _write_meme_workspace(workspace: Path) -> Path: @@ -50,48 +97,21 @@ def _write_meme_workspace(workspace: Path) -> Path: return image -async def _make_plugin(tmp_path: Path) -> MemePlugin: - plugin_dir = tmp_path / "plugin" - plugin_dir.mkdir(parents=True) - plugin = MemePlugin() - plugin.context = PluginContext( - event_bus=None, - tool_registry=None, - plugin_id="meme", - plugin_dir=plugin_dir, - data_dir=tmp_path, - kv_store=PluginKVStore(plugin_dir / ".kv.json"), - workspace=tmp_path, +def test_static_manifest_matches_v3_module() -> None: + manifest = load_static_plugin_manifest( + Path(_meme_plugin_module.__file__ or "").resolve().parent ) - await plugin.prepare() - return plugin - - -def test_catalog_builds_prompt_block(tmp_path: Path) -> None: - _write_meme_workspace(tmp_path) - block = MemeCatalog(tmp_path / "memes").build_prompt_block() - assert block is not None - assert "" in block - assert "只有当你真的要发这个表情时" in block - assert "代码样式的 ``" in block + assert manifest.name == _meme_plugin_module.name == "meme" + assert manifest.version == _meme_plugin_module.version == "1.0.0" + assert manifest.api_version == _meme_plugin_module.api_version == 3 + assert manifest.entrypoint == "plugin.py" -def test_decorator_picks_image_for_tag(tmp_path: Path) -> None: - image = _write_meme_workspace(tmp_path) - result = MemeDecorator(MemeCatalog(tmp_path / "memes")).decorate("好的", meme_tag="shy") - assert result.content == "好的" - assert result.media == [str(image)] - -@pytest.mark.asyncio -async def test_meme_prompt_module_injects_bottom_section(tmp_path: Path) -> None: - _write_meme_workspace(tmp_path) - plugin = await _make_plugin(tmp_path) - module = plugin.prompt_render_modules()[0] - assert isinstance(module, MemePromptModule) - ctx = PromptRenderCtx( - session_key="telegram:1", - channel="telegram", +def _prompt_ctx() -> PromptRenderCtx: + return PromptRenderCtx( + session_key="webui:1", + channel="webui", chat_id="1", content="你好", media=None, @@ -102,74 +122,325 @@ async def test_meme_prompt_module_injects_bottom_section(tmp_path: Path) -> None disabled_sections=set(), turn_injection_prompt="", ) - frame = SimpleNamespace(slots={"prompt:ctx": ctx}) - await module.run(frame) - assert ctx.system_sections_bottom[0].name == "memes" -@pytest.mark.asyncio -async def test_meme_plugin_decorates_after_reasoning(tmp_path: Path) -> None: - image = _write_meme_workspace(tmp_path) - plugin = await _make_plugin(tmp_path) - ctx = AfterReasoningCtx( - session_key="telegram:1", - channel="telegram", +def _answer_ctx(reply: str) -> AfterReasoningCtx: + return AfterReasoningCtx( + session_key="webui:1", + channel="webui", chat_id="1", tools_used=(), thinking=None, - response_metadata=ResponseMetadata(raw_text="好的 "), + response_metadata=ResponseMetadata(raw_text=reply), streamed=False, tool_chain=(), context_retry={}, - reply="好的 ", + reply=reply, + ) + + +def test_catalog_builds_prompt_block(tmp_path: Path) -> None: + _ = _write_meme_workspace(tmp_path) + block = MemeCatalog(tmp_path / "memes").build_prompt_block() + assert block is not None + assert "" in block + assert "只有当你真的要发这个表情时" in block + assert "代码样式的 ``" in block + + +def test_decorator_picks_image_for_tag(tmp_path: Path) -> None: + image = _write_meme_workspace(tmp_path) + result = MemeDecorator(MemeCatalog(tmp_path / "memes")).decorate( + "好的", meme_tag="shy" ) - out = await plugin.decorate_meme(ctx) - assert out.reply == "好的" - assert out.media == [str(image)] - assert out.meme_tag == "shy" + assert result.content == "好的" + assert result.media == [str(image)] + + +def test_decorate_meme_ctx_updates_answer_metadata(tmp_path: Path) -> None: + image = _write_meme_workspace(tmp_path) + ctx = _answer_ctx("好的 ") + decorate_meme_ctx(ctx, MemeDecorator(MemeCatalog(tmp_path / "memes"))) + assert ctx.reply == "好的" + assert ctx.media == [str(image)] + assert ctx.meme_tag == "shy" + + +def test_decorate_meme_ctx_accepts_inline_tag(tmp_path: Path) -> None: + image = _write_meme_workspace(tmp_path) + ctx = _answer_ctx("快了 \n\n马上到了") + decorate_meme_ctx(ctx, MemeDecorator(MemeCatalog(tmp_path / "memes"))) + assert ctx.reply == "快了\n\n马上到了" + assert ctx.media == [str(image)] + assert ctx.meme_tag == "shy" + + +def test_decorate_meme_ctx_ignores_code_tag(tmp_path: Path) -> None: + _ = _write_meme_workspace(tmp_path) + ctx = _answer_ctx("应该是 ``。\n\n<æm>shy") + decorate_meme_ctx(ctx, MemeDecorator(MemeCatalog(tmp_path / "memes"))) + assert ctx.reply == "应该是 ``。\n\n<æm>shy" + assert ctx.media == [] + assert ctx.meme_tag is None @pytest.mark.asyncio -async def test_meme_plugin_accepts_inline_tag(tmp_path: Path) -> None: +async def test_v3_named_exports_run_complete_lifecycle_behavior( + tmp_path: Path, +) -> None: image = _write_meme_workspace(tmp_path) - plugin = await _make_plugin(tmp_path) - ctx = AfterReasoningCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata(raw_text="快了 \n\n马上到了"), - streamed=False, - tool_chain=(), - context_retry={}, - reply="快了 \n\n马上到了", + composable = ComposablePlugin.from_module(_meme_plugin_module) + assert composable.skill_roots == ("skills",) + assert composable.dashboard_module == "dashboard.py" + assert composable.workspace_roots == ("memes",) + root = CompositionRoot("meme-v3") + _ = await root.context.provide(CITATION_PROTOCOL_SERVICE, object()) + + async def mount(ctx: Context) -> None: + await apply(ctx, object()) + + _ = await root.mount( + mount, + name="meme", + inject=inject, + runtime=PluginRuntime( + plugin_id="meme", + plugin_dir=Path(__file__).parents[1], + data_dir=tmp_path / "plugin-data", + workspace=tmp_path, + config=object(), + workspace_roots=("memes",), + ), ) - out = await plugin.decorate_meme(ctx) - assert out.reply == "快了\n\n马上到了" - assert out.media == [str(image)] - assert out.meme_tag == "shy" + receipt = root.receipt() + assert receipt.ready is True + assert receipt.writes == () + assert receipt.external_effects == () + + prompt = _prompt_ctx() + _ = await root.context.serial(PROMPT_RENDER_EVENT, prompt) + assert [section.name for section in prompt.system_sections_bottom] == ["memes"] + + answer = _answer_ctx("好的 ") + _ = await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, answer) + assert answer.reply == "好的" + assert answer.media == [str(image)] + assert answer.meme_tag == "shy" + + await root.dispose() + assert root.receipt().effects == () + assert root.topology_view().listeners == () @pytest.mark.asyncio -async def test_meme_plugin_ignores_code_tag(tmp_path: Path) -> None: - _write_meme_workspace(tmp_path) - plugin = await _make_plugin(tmp_path) - ctx = AfterReasoningCtx( - session_key="telegram:1", - channel="telegram", - chat_id="1", - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata( - raw_text="应该是 ``。\n\n<æm>shy" +async def test_v3_candidate_reads_only_its_projected_meme_root( + tmp_path: Path, +) -> None: + formal_workspace = tmp_path / "formal-workspace" + formal_image = _write_meme_workspace(formal_workspace) + candidate_workspace = ( + tmp_path + / "runtime" + / "plugin-validation" + / "meme" + / "composition" + / "attempt" + / "workspace" + ) + _ = shutil.copytree( + formal_workspace / "memes", + candidate_workspace / "memes", + ) + candidate_image = candidate_workspace / "memes" / "shy" / "001.png" + before = { + path.relative_to(candidate_workspace).as_posix(): path.read_bytes() + for path in candidate_workspace.rglob("*") + if path.is_file() + } + root = CompositionRoot("meme-candidate") + _ = await root.context.provide(CITATION_PROTOCOL_SERVICE, object()) + + async def mount(ctx: Context) -> None: + await apply(ctx, object()) + + _ = await root.mount( + mount, + name="meme", + inject=inject, + runtime=PluginRuntime( + plugin_id="meme", + plugin_dir=Path(__file__).parents[1], + data_dir=tmp_path / "candidate-data", + workspace=candidate_workspace, + config=object(), + workspace_roots=("memes",), + ), + ) + prompt = _prompt_ctx() + _ = await root.context.serial(PROMPT_RENDER_EVENT, prompt) + answer = _answer_ctx("好的 ") + _ = await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, answer) + + dashboard_module = importlib.import_module("test_meme_plugin.dashboard") + app = FastAPI() + dashboard_module.register( + app, + DashboardContext( + plugin_id="meme", + plugin_dir=Path(__file__).parents[1], + data_root=tmp_path / "candidate-data", + validation=True, + _workspace_roots=(("memes", candidate_workspace / "memes"),), ), - streamed=False, - tool_chain=(), - context_retry={}, - reply="应该是 ``。\n\n<æm>shy", ) - out = await plugin.decorate_meme(ctx) - assert out.reply == "应该是 ``。\n\n<æm>shy" - assert out.media == [] - assert out.meme_tag is None + candidate_route = next( + route + for route in app.routes + if isinstance(route, APIRoute) + and route.path == "/api/dashboard/meme/categories" + ) + categories = candidate_route.endpoint() + + after = { + path.relative_to(candidate_workspace).as_posix(): path.read_bytes() + for path in candidate_workspace.rglob("*") + if path.is_file() + } + assert before == after + assert formal_image.read_bytes() == candidate_image.read_bytes() + assert answer.media == [str(candidate_image)] + assert categories["categories"][0]["tag"] == "shy" + assert root.receipt().writes == () + assert root.receipt().external_effects == () + await root.dispose() + + +@pytest.mark.asyncio +async def test_v3_plugin_loads_package_and_dashboard_through_real_manager( + tmp_path: Path, +) -> None: + _ = _write_meme_workspace(tmp_path / "workspace") + plugin_home = tmp_path / "plugins" + citation_dir = plugin_home / "citation" + citation_dir.mkdir(parents=True) + (citation_dir / "plugin.py").write_text( + "from agent.plugin_composition import ServiceKey\n" + "api_version = 3\n" + "name = 'citation'\n" + "version = '1.0.0'\n" + "SERVICE = ServiceKey('citation.protocol')\n" + "async def apply(ctx, config):\n" + " await ctx.provide(SERVICE, object())\n", + encoding="utf-8", + ) + _ = shutil.copytree( + Path(__file__).parents[1], + plugin_home / "meme", + ignore=_copy_ignore(), + ) + workspace = tmp_path / "workspace" + manager = PluginManager( + plugin_dirs=[plugin_home], + event_bus=EventBus(), + tool_registry=None, + workspace=workspace, + installed_cache_root=tmp_path / "plugin-home" / "cache", + ) + + await manager.load_all() + + generation = manager.generation("meme") + snapshot = manager.current_snapshot + assert generation is not None and snapshot is not None + assert isinstance(generation.instance, ComposablePlugin) + assert generation.contributions.skill_roots == (plugin_home / "meme" / "skills",) + assert generation.contributions.dashboard_module == ( + plugin_home / "meme" / "dashboard.py" + ) + assert generation.instance.workspace_roots == ("memes",) + assert snapshot.plugin_skill_index is not None + assert "meme-manage" in snapshot.plugin_skill_index.records + + dashboard = PluginDashboardHost( + core_routes=(), + ) + dashboard.prepare_snapshot(snapshot) + assert len(snapshot.dashboard_bindings) == 1 + binding = snapshot.dashboard_bindings[0] + assert isinstance(binding, DashboardBinding) + assert binding.plugin_id == "meme" + assert binding.validation is False + assert binding.runtime_workspace == workspace.resolve() + categories = next( + route.endpoint + for route in binding.routes + if route.path == "/api/dashboard/meme/categories" + )() + assert categories["categories"][0]["tag"] == "shy" + + root = snapshot.composition_root + assert root is not None + await manager.terminate_all() + assert root.receipt().effects == () + assert root.topology_view().listeners == () + + +@pytest.mark.asyncio +async def test_citation_meme_cross_repository_v3_behavior(tmp_path: Path) -> None: + raw_citation_root = os.environ.get("AKASHIC_CITATION_ROOT", "").strip() + if not raw_citation_root: + raise RuntimeError( + "AKASHIC_CITATION_ROOT 必须指向 exact-commit Citation checkout" + ) + citation_root = Path(raw_citation_root) + citation_module = _load_exact_citation_module(citation_root) + assert not hasattr(citation_module, "CitationPlugin") + + workspace = tmp_path / "workspace" + image = _write_meme_workspace(workspace) + plugin_home = tmp_path / "plugins" + _ = shutil.copytree( + citation_root, + plugin_home / "citation", + ignore=_copy_ignore(), + ) + _ = shutil.copytree( + Path(__file__).parents[1], + plugin_home / "meme", + ignore=_copy_ignore(), + ) + manager = PluginManager( + plugin_dirs=[plugin_home], + event_bus=EventBus(), + tool_registry=None, + workspace=workspace, + installed_cache_root=tmp_path / "plugin-home" / "cache", + ) + await manager.load_all() + snapshot = manager.current_snapshot + assert snapshot is not None and snapshot.composition_root is not None + + prompt = _prompt_ctx() + _ = await snapshot.composition_root.context.serial(PROMPT_RENDER_EVENT, prompt) + answer = _answer_ctx("答复正文\n§cited:[mem_1]§ ") + _ = await snapshot.composition_root.context.serial( + AFTER_REASONING_PREPROCESS_EVENT, + answer, + ) + _ = await snapshot.composition_root.context.serial( + AFTER_REASONING_CLEANUP_EVENT, + answer, + ) + + assert [section.name for section in prompt.system_sections_bottom] == [ + "citation_protocol", + "memes", + ] + assert answer.reply == "答复正文" + assert answer.persist_assistant_metadata["cited_memory_ids"] == ["mem_1"] + assert answer.media == [str(image)] + assert answer.meme_tag == "shy" + root = snapshot.composition_root + await manager.terminate_all() + assert root.receipt().effects == () + assert root.topology_view().listeners == ()