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..5f8f753 --- /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: a047470a39d4f7d2e6be1d2a8e2824916d52fad1 + path: .akashic-core + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/citation + ref: a9abeb31c25458b8e799dc6aae25d3e83b912c83 + 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: Compare v2 and v3 Meme receipts + 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..fe20f82 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,19 @@ | 接入方式 | 阶段 | |---|---| -| `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 仍由插件拥有。旧 `MemePlugin` 暂时保留,只用于迁移期差分验证。 --- ## 运作逻辑 -### 1. 初始化(initialize) +### 1. 初始化 从工作区路径(`workspace/memes/`)加载 `manifest.json`,构建 `MemeCatalog` 和 `MemeDecorator` 实例。`MemeCatalog` 按需检测 manifest 的 mtime,变动时自动热重载,不需要重启。 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..7f6e1f6 100644 --- a/plugin.py +++ b/plugin.py @@ -4,7 +4,12 @@ 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.plugin_composition import Context, ServiceKey from agent.plugins import Plugin, on_after_reasoning from agent.prompting import PromptSectionRender from .runtime import MemeCatalog, MemeDecorator @@ -15,6 +20,29 @@ re.IGNORECASE, ) +CITATION_PROTOCOL_SERVICE = ServiceKey[object]("citation.protocol") + + +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, + ) + ) + + +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 + class MemePromptModule: slot = "meme.prompt" @@ -28,21 +56,41 @@ 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, - ) - ) + append_meme_prompt(ctx, self._plugin.catalog) return frame +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) + + class MemePlugin(Plugin): api_version = 2 + @classmethod def dashboard_module(cls) -> str | None: return "dashboard.py" @@ -53,11 +101,14 @@ def dashboard_module(cls) -> str | None: @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" + memes_dir = ( + _workspace(self.context.plugin_dir, self.context.workspace) / "memes" + ) self._catalog = MemeCatalog(memes_dir) self._decorator = MemeDecorator(self._catalog) @@ -66,11 +117,7 @@ def prompt_render_modules(self) -> list[object]: @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 + decorate_meme_ctx(ctx, self.decorator) return ctx @property diff --git a/tests/test_plugin.py b/tests/test_plugin.py index d4091a9..16c36d1 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,17 +1,38 @@ from __future__ import annotations import json +import importlib import importlib.util +import os +import shutil from datetime import datetime, timezone from types import SimpleNamespace from pathlib import Path import sys 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.plugin_composition import ( + CompositionRoot, + Context, + DashboardContext, + PluginRuntime, +) +from agent.plugins.composable import ComposablePlugin from agent.plugins.context import PluginContext, PluginKVStore +from agent.plugins.dashboard_host import DashboardBinding, PluginDashboardHost +from agent.plugins.manager import PluginManager +from agent.plugins.scope import PluginScope, ScopedEventBus +from bus.event_bus import EventBus from runtime import MemeCatalog, MemeDecorator @@ -33,6 +54,20 @@ def _load_meme_plugin_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 +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: @@ -53,15 +88,17 @@ def _write_meme_workspace(workspace: Path) -> Path: async def _make_plugin(tmp_path: Path) -> MemePlugin: plugin_dir = tmp_path / "plugin" plugin_dir.mkdir(parents=True) + scope = PluginScope("meme") plugin = MemePlugin() plugin.context = PluginContext( - event_bus=None, + event_bus=ScopedEventBus(EventBus(), scope), tool_registry=None, plugin_id="meme", plugin_dir=plugin_dir, data_dir=tmp_path, kv_store=PluginKVStore(plugin_dir / ".kv.json"), workspace=tmp_path, + scope=scope, ) await plugin.prepare() return plugin @@ -78,7 +115,9 @@ def test_catalog_builds_prompt_block(tmp_path: Path) -> None: 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") + result = MemeDecorator(MemeCatalog(tmp_path / "memes")).decorate( + "好的", meme_tag="shy" + ) assert result.content == "好的" assert result.media == [str(image)] @@ -173,3 +212,408 @@ async def test_meme_plugin_ignores_code_tag(tmp_path: Path) -> None: assert out.reply == "应该是 ``。\n\n<æm>shy" assert out.media == [] assert out.meme_tag is None + + +@pytest.mark.asyncio +async def test_v3_named_exports_match_legacy_behavior(tmp_path: Path) -> None: + image = _write_meme_workspace(tmp_path) + legacy = await _make_plugin(tmp_path) + 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-parity") + _ = await root.context.provide(CITATION_PROTOCOL_SERVICE, object()) + + async def mount(ctx: Context) -> None: + await apply(ctx, object()) + + plugin_dir = Path(__file__).parents[1] + _ = await root.mount( + mount, + name="meme", + inject=inject, + runtime=PluginRuntime( + plugin_id="meme", + plugin_dir=plugin_dir, + data_dir=tmp_path / "plugin-data", + workspace=tmp_path, + config=object(), + workspace_roots=("memes",), + ), + ) + receipt = root.receipt() + assert receipt.ready is True + assert receipt.writes == () + assert receipt.external_effects == () + + legacy_prompt = PromptRenderCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + content="你好", + media=None, + timestamp=datetime.now(timezone.utc), + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + await legacy.prompt_render_modules()[0].run( + SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) + ) + v3_prompt = PromptRenderCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + content="你好", + media=None, + timestamp=legacy_prompt.timestamp, + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + _ = await root.context.serial(PROMPT_RENDER_EVENT, v3_prompt) + assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom + + legacy_answer = AfterReasoningCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text="好的 "), + streamed=False, + tool_chain=(), + context_retry={}, + reply="好的 ", + ) + await legacy.decorate_meme(legacy_answer) + v3_answer = AfterReasoningCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text="好的 "), + streamed=False, + tool_chain=(), + context_retry={}, + reply="好的 ", + ) + _ = await root.context.serial(AFTER_REASONING_PREPROCESS_EVENT, v3_answer) + + assert v3_answer.reply == legacy_answer.reply == "好的" + assert v3_answer.media == legacy_answer.media == [str(image)] + assert v3_answer.meme_tag == legacy_answer.meme_tag == "shy" + await root.dispose() + assert root.receipt().effects == () + assert root.topology_view().listeners == () + + +@pytest.mark.asyncio +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 = PromptRenderCtx( + session_key="webui:1", + channel="webui", + chat_id="1", + content="你好", + media=None, + timestamp=datetime.now(timezone.utc), + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + _ = await root.context.serial(PROMPT_RENDER_EVENT, prompt) + answer = AfterReasoningCtx( + session_key="webui:1", + channel="webui", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text="好的 "), + streamed=False, + tool_chain=(), + context_retry={}, + reply="好的 ", + ) + _ = 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"),), + ), + ) + 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( + workspace=workspace, + memory_admin=object(), + memory_store=object(), + 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_parity(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_spec = importlib.util.spec_from_file_location( + "test_citation_plugin", + citation_root / "plugin.py", + ) + if citation_spec is None or citation_spec.loader is None: + raise ImportError(str(citation_root / "plugin.py")) + citation_module = importlib.util.module_from_spec(citation_spec) + sys.modules[citation_spec.name] = citation_module + citation_spec.loader.exec_module(citation_module) + + workspace = tmp_path / "workspace" + image = _write_meme_workspace(workspace) + legacy_meme = await _make_plugin(workspace) + legacy_prompt = PromptRenderCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + content="你好", + media=None, + timestamp=datetime.now(timezone.utc), + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + await citation_module.CitationPromptModule().run( + SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) + ) + await legacy_meme.prompt_render_modules()[0].run( + SimpleNamespace(slots={"prompt:ctx": legacy_prompt}) + ) + reply = "答复正文\n§cited:[mem_1]§ " + legacy_answer = AfterReasoningCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text=reply), + streamed=False, + tool_chain=(), + context_retry={}, + reply=reply, + ) + legacy_frame = SimpleNamespace(slots={"reasoning:ctx": legacy_answer}) + await citation_module.CitationAfterReasoningModule().run(legacy_frame) + await legacy_meme.decorate_meme(legacy_answer) + await citation_module.ProtocolTagCleanupModule().run(legacy_frame) + + 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 + + v3_prompt = PromptRenderCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + content="你好", + media=None, + timestamp=legacy_prompt.timestamp, + history=[], + skill_names=[], + retrieved_memory_block="", + disabled_sections=set(), + turn_injection_prompt="", + ) + _ = await snapshot.composition_root.context.serial( + PROMPT_RENDER_EVENT, + v3_prompt, + ) + v3_answer = AfterReasoningCtx( + session_key="telegram:1", + channel="telegram", + chat_id="1", + tools_used=(), + thinking=None, + response_metadata=ResponseMetadata(raw_text=reply), + streamed=False, + tool_chain=(), + context_retry={}, + reply=reply, + ) + _ = await snapshot.composition_root.context.serial( + AFTER_REASONING_PREPROCESS_EVENT, + v3_answer, + ) + _ = await snapshot.composition_root.context.serial( + AFTER_REASONING_CLEANUP_EVENT, + v3_answer, + ) + + assert v3_prompt.system_sections_bottom == legacy_prompt.system_sections_bottom + assert v3_answer.reply == legacy_answer.reply == "答复正文" + assert v3_answer.persist_assistant_metadata["cited_memory_ids"] == ( + legacy_frame.slots["persist:assistant:cited_memory_ids"] + ) + assert v3_answer.media == legacy_answer.media == [str(image)] + assert v3_answer.meme_tag == legacy_answer.meme_tag == "shy" + await manager.terminate_all()