From 98aa11cd9317a34e7c6f4289cf70944bcc80bb9f Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 19:09:34 +0800 Subject: [PATCH 1/6] feat(plugin): migrate undo to api v3 --- .github/workflows/plugin-api-v2.yml | 28 --- .github/workflows/plugin-api-v3.yml | 62 ++++++ README.md | 12 ++ akashic.plugin.toml | 5 + plugin.py | 313 ++++------------------------ tests/test_plugin.py | 292 ++++++++++++++++++-------- 6 files changed, 321 insertions(+), 391 deletions(-) delete mode 100644 .github/workflows/plugin-api-v2.yml create mode 100644 .github/workflows/plugin-api-v3.yml create mode 100644 README.md create mode 100644 akashic.plugin.toml 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..5463282 --- /dev/null +++ b/.github/workflows/plugin-api-v3.yml @@ -0,0 +1,62 @@ +name: plugin-api-v3 + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +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: 189c25a3e011c90cc8106fdda0b57c8c0ae71730 + path: .akashic-core + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: .akashic-core/requirements.txt + - name: Install exact Core runtime + run: | + python -m venv .venv + .venv/bin/python -m pip install -r .akashic-core/requirements.txt pytest pytest-asyncio + - name: Verify Plugin Undo v3 composition + env: + AKASHIC_AGENT_ROOT: ${{ github.workspace }}/.akashic-core + PYTHONPATH: ${{ github.workspace }}/.akashic-core + run: cd tests && ../.venv/bin/python -m pytest -q . + - name: Check v3 source types + env: + PYTHONPATH: .akashic-core + run: .venv/bin/pyright --level error plugin.py + - name: Compile Python sources + run: python -m compileall -q . + - name: Check diff formatting + run: git diff --check diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ddf46d --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Plugin Undo + +Plugin Undo 提供 `/undo`,撤销当前 Session 最后一个完整的 completed interaction。 + +插件是 pure Plugin API v3,只声明 `COMMANDS` 与 `INTERACTION_UNDO`。SessionDB backup、 +interaction transcript/embedding 删除、Default Memory durable reconciliation 和 Akasha rebuild +都由 Core `189c25a3e011c90cc8106fdda0b57c8c0ae71730` 拥有;插件不接触 SessionManager、SQL、 +memory engine 或正式 workspace。 + +候选 generation 只验证拓扑与 command catalog,不能调用 destructive owner。正式 `/undo` 若 +Session 删除已提交但 Default Memory 尚待收敛,会明确返回 error 结果,Core 在当前进程 retry 或 +进程重启时重放 pending receipt。 diff --git a/akashic.plugin.toml b/akashic.plugin.toml new file mode 100644 index 0000000..fdc1a65 --- /dev/null +++ b/akashic.plugin.toml @@ -0,0 +1,5 @@ +schema_version = 1 +name = "plugin_undo" +version = "2.0.0" +api_version = 3 +entrypoint = "plugin.py" diff --git a/plugin.py b/plugin.py index 8d1693d..69d824b 100644 --- a/plugin.py +++ b/plugin.py @@ -1,287 +1,50 @@ from __future__ import annotations -import logging -from dataclasses import dataclass -from datetime import datetime -from typing import Any, cast +from agent.plugin_composition import ( + COMMANDS, + INTERACTION_UNDO, + CommandDefinition, + CommandInvocation, + CommandResult, +) -from agent.lifecycle.types import BeforeTurnCtx -from agent.plugins import Plugin -from agent.prompting import is_context_frame +api_version = 3 +name = "plugin_undo" +version = "2.0.0" +inject = (COMMANDS, INTERACTION_UNDO) -logger = logging.getLogger("plugin.undo") -_SESSION_SLOT = "session:session" -_CTX_SLOT = "session:ctx" +async def apply(ctx, config) -> None: + """注册只调用 Core destructive owner 的 `/undo` 命令。""" + _ = config + undo = ctx.require(INTERACTION_UNDO) -@dataclass -class _UndoSessionResult: - deleted_ids: list[str] - target_user_id: str - target_assistant_id: str - rollback_index: int - last_consolidated_before: int - last_consolidated_after: int - - -class UndoCommandModule: - slot = "plugin_undo.undo" - requires = ("before_turn.acquire_session", _SESSION_SLOT) - produces = (_CTX_SLOT,) - - def __init__(self, plugin: "PluginUndo") -> None: - self._plugin = plugin - - async def run(self, frame) -> object: - if _CTX_SLOT in frame.slots: - return frame - state = frame.input - if _normalize_command(state.msg.content) != "/undo": - return frame - reply = await self._plugin.undo(state.session_key) - frame.slots[_CTX_SLOT] = _abort_ctx(state, reply) - return frame - - -class PluginUndo(Plugin): - api_version = 2 - name = "plugin_undo" - version = "1.0.0" - - def telegram_bot_commands(self) -> list[tuple[str, str]]: - return [("undo", "撤销上一轮对话")] - - def before_turn_modules(self) -> list[object]: - return [UndoCommandModule(self)] - - async def undo(self, session_key: str) -> str: - session_manager = getattr(self.context, "session_manager", None) - if session_manager is None: - return "撤销失败:session 管理器不可用。" - memory_result: dict[str, object] = { - "affected_ids": [], - "restored_ids": [], - "rollback_source_ids": [], - } - message_ids_for_memory: list[str] = [] - - def resolve_sources(message_ids: list[str]) -> list[str]: - nonlocal memory_result, message_ids_for_memory - message_ids_for_memory = list(message_ids) - memory_result = _undo_memory_sources( - getattr(self.context, "memory_engine", None), - message_ids, - dry_run=True, - ) - return _string_list(memory_result.get("rollback_source_ids")) - - result = await _undo_last_turn( - session_manager, - session_key, - rollback_source_resolver=resolve_sources, - ) + async def handle(invocation: CommandInvocation) -> CommandResult: + result = await undo.undo_latest(invocation.session_key) if result is None: - return "没有可撤销的上一轮对话。" - try: - memory_result = _undo_memory_sources( - getattr(self.context, "memory_engine", None), - message_ids_for_memory or result.deleted_ids, - dry_run=False, - ) - except Exception: - logger.exception( - "undo memory cleanup failed after session delete: session=%s deleted_ids=%s dry_run=%s", - session_key, - result.deleted_ids, - memory_result, - ) - return ( - "已撤销上一轮对话,但记忆清理失败。" - f"\n删除消息:{len(result.deleted_ids)} 条" - "\n请查看日志后手动清理对应记忆。" + return CommandResult(kind="success", text="没有可撤销的上一轮对话。") + if result.reconciliation_pending: + return CommandResult( + kind="error", + text=( + "上一轮对话已撤销,但派生记忆仍在等待 Core 重试收敛。" + f"\n删除消息:{len(result.message_ids)} 条" + ), ) - logger.info( - "undo session=%s deleted=%d memory_superseded=%d memory_restored=%d last=%d->%d", - session_key, - len(result.deleted_ids), - len(_string_list(memory_result.get("affected_ids"))), - len(_string_list(memory_result.get("restored_ids"))), - result.last_consolidated_before, - result.last_consolidated_after, - ) - return ( - "已撤销上一轮对话。" - f"\n删除消息:{len(result.deleted_ids)} 条" - f"\n失效记忆:{len(_string_list(memory_result.get('affected_ids')))} 条" - f"\n恢复旧记忆:{len(_string_list(memory_result.get('restored_ids')))} 条" - ) - - -async def _undo_last_turn( - session_manager: Any, - session_key: str, - *, - rollback_source_ids: list[str] | None = None, - expected_message_ids: list[str] | None = None, - rollback_source_resolver: Any = None, -) -> _UndoSessionResult | None: - async with session_manager._lock(session_key): - session = session_manager.get_or_create(session_key) - target = _find_last_passive_turn(session.messages) - if target is None: - return None - delete_indices, user_index, assistant_index = target - deleted_ids = [ - str(session.messages[i].get("id") or "") - for i in delete_indices - if str(session.messages[i].get("id") or "").strip() - ] - if len(deleted_ids) != len(delete_indices): - return None - expected = [ - str(message_id).strip() - for message_id in (expected_message_ids or []) - if str(message_id).strip() - ] - if expected and expected != deleted_ids: - return None - if rollback_source_resolver is not None: - rollback_source_ids = rollback_source_resolver(list(deleted_ids)) - target_user_id = str(session.messages[user_index].get("id") or "") - target_assistant_id = str(session.messages[assistant_index].get("id") or "") - old_last = max(0, int(session.last_consolidated)) - rollback_index = _compute_rollback_index( - session.messages, - delete_indices=delete_indices, - old_last_consolidated=old_last, - rollback_source_ids=rollback_source_ids or [], + return CommandResult( + kind="success", + text=( + "已撤销上一轮对话。" + f"\n删除消息:{len(result.message_ids)} 条" + ), ) - delete_set = set(delete_indices) - remaining = [ - msg for i, msg in enumerate(session.messages) if i not in delete_set - ] - deleted_before = sum(1 for i in delete_indices if i < rollback_index) - new_last = max(0, rollback_index - deleted_before) - new_last = min(new_last, len(remaining)) - deleted_count = session_manager._store.delete_session_messages_and_update_cursor( - session.key, - ids=deleted_ids, - last_consolidated=new_last, - ) - if deleted_count != len(deleted_ids): - session_manager.invalidate(session.key) - return None - session.messages = remaining - session.last_consolidated = new_last - session.updated_at = datetime.now() - session_manager._cache[session.key] = session - return _UndoSessionResult( - deleted_ids=deleted_ids, - target_user_id=target_user_id, - target_assistant_id=target_assistant_id, - rollback_index=rollback_index, - last_consolidated_before=old_last, - last_consolidated_after=new_last, - ) - - -def _is_context_frame_message(message: dict[str, Any]) -> bool: - if message.get("role") != "user": - return False - return is_context_frame(str(message.get("content") or "")) - - -def _is_real_user_message(message: dict[str, Any]) -> bool: - return message.get("role") == "user" and not _is_context_frame_message(message) - - -def _is_passive_assistant_message(message: dict[str, Any]) -> bool: - return message.get("role") == "assistant" and not bool(message.get("proactive")) - - -def _find_last_passive_turn( - messages: list[dict[str, Any]], -) -> tuple[list[int], int, int] | None: - for assistant_index in range(len(messages) - 1, -1, -1): - if not _is_passive_assistant_message(messages[assistant_index]): - continue - user_index = assistant_index - 1 - while user_index >= 0 and _is_context_frame_message(messages[user_index]): - user_index -= 1 - if user_index < 0 or not _is_real_user_message(messages[user_index]): - continue - delete_indices = [user_index, assistant_index] - context_index = user_index - 1 - while context_index >= 0 and _is_context_frame_message(messages[context_index]): - delete_indices.insert(0, context_index) - context_index -= 1 - return delete_indices, user_index, assistant_index - return None - - -def _compute_rollback_index( - messages: list[dict[str, Any]], - *, - delete_indices: list[int], - old_last_consolidated: int, - rollback_source_ids: list[str], -) -> int: - if not delete_indices: - return min(old_last_consolidated, len(messages)) - rollback_index = min(delete_indices) - if rollback_index >= old_last_consolidated: - return min(old_last_consolidated, len(messages) - len(delete_indices)) - source_ids = {str(item).strip() for item in rollback_source_ids if str(item).strip()} - for index, message in enumerate(messages): - msg_id = str(message.get("id") or "").strip() - if msg_id and msg_id in source_ids: - rollback_index = min(rollback_index, index) - return max(0, min(rollback_index, old_last_consolidated)) - - -def _undo_memory_sources( - memory_engine: Any, - message_ids: list[str], - *, - dry_run: bool, -) -> dict[str, object]: - if memory_engine is None: - return {"affected_ids": [], "restored_ids": [], "rollback_source_ids": []} - undo = getattr(memory_engine, "undo_by_message_sources", None) - if not callable(undo): - return {"affected_ids": [], "restored_ids": [], "rollback_source_ids": []} - result = undo(message_ids, dry_run=dry_run) - return cast(dict[str, object], result if isinstance(result, dict) else {}) - - -def _string_list(value: object) -> list[str]: - if not isinstance(value, list): - return [] - return [str(item) for item in value if str(item).strip()] - - -def _normalize_command(content: str) -> str: - parts = (content or "").strip().split(maxsplit=1) - if not parts: - return "" - head = parts[0].lower() - if "@" in head: - head = head.split("@", 1)[0] - return head - -def _abort_ctx(state, reply: str) -> BeforeTurnCtx: - return BeforeTurnCtx( - session_key=state.session_key, - channel=state.msg.channel, - chat_id=state.msg.chat_id, - content=state.msg.content, - timestamp=state.msg.timestamp, - skill_names=[], - retrieved_memory_block="", - retrieval_trace_raw=None, - history_messages=(), - abort=True, - abort_reply=reply, + await ctx.require(COMMANDS).register( + ctx, + CommandDefinition( + name="undo", + description="撤销上一轮对话", + handler=handle, + ), ) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 1b2a433..b0ea27e 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,21 +1,34 @@ from __future__ import annotations -import asyncio -from datetime import datetime -from types import SimpleNamespace +import shutil +from datetime import UTC, datetime from pathlib import Path +from types import SimpleNamespace import pytest -from agent.plugins.context import PluginContext, PluginKVStore -from plugin import PluginUndo, UndoCommandModule, _find_last_passive_turn, _undo_last_turn +from agent.plugin_composition import ( + COMMANDS, + INTERACTION_UNDO, + CommandExecution, + CommandRegistry, + InteractionUndoResult, + InteractionUndoService, + PluginCommands, +) +from agent.plugin_composition.context import CompositionRoot, PluginRuntime +from agent.plugins.manager import PluginManager +from bus.event_bus import EventBus +from plugin import apply from session.manager import SessionManager -class _MemoryEngine: - def __init__(self, *, fail_real_undo: bool = False) -> None: - self.calls: list[dict[str, object]] = [] - self.fail_real_undo = fail_real_undo +class _DefaultMemory: + def __init__(self) -> None: + self.calls: list[tuple[str, ...]] = [] + + def describe(self) -> SimpleNamespace: + return SimpleNamespace(name="default") def undo_by_message_sources( self, @@ -23,90 +36,193 @@ def undo_by_message_sources( *, dry_run: bool = False, ) -> dict[str, object]: - self.calls.append({"message_ids": list(message_ids), "dry_run": dry_run}) - if self.fail_real_undo and not dry_run: - raise RuntimeError("memory cleanup failed") - return { - "affected_ids": ["mem1"], - "restored_ids": ["old1"], - "rollback_source_ids": ["cli:1:0", "cli:1:1", "cli:1:2"], - } + assert dry_run is False + self.calls.append(tuple(message_ids)) + return {"affected_ids": [], "restored_ids": []} -def _run(coro): - return asyncio.run(coro) +async def _registry( + result: InteractionUndoResult | None, +) -> tuple[CompositionRoot, CommandRegistry]: + root = CompositionRoot("undo-test") + commands = PluginCommands() + _ = await root.context.provide(COMMANDS, commands) + async def undo_latest(_session_key: str) -> InteractionUndoResult | None: + return result -@pytest.mark.asyncio -async def test_undo_command_aborts_without_running_llm(tmp_path) -> None: - plugin = PluginUndo() - session_manager = SessionManager(tmp_path) - session = session_manager.get_or_create("cli:1") - session.add_message("user", '内部') - session.add_message("user", "u0") - session.add_message("assistant", "a0") - session_manager.save(session) - memory_engine = _MemoryEngine() - plugin.context = PluginContext( - event_bus=None, - tool_registry=None, + _ = await root.context.provide( + INTERACTION_UNDO, + InteractionUndoService(undo_latest), + ) + runtime = PluginRuntime( plugin_id="plugin_undo", - plugin_dir=tmp_path, - data_dir=tmp_path, - kv_store=PluginKVStore(tmp_path / ".kv.json"), - session_manager=session_manager, - memory_engine=memory_engine, + plugin_dir=Path("/plugin"), + data_dir=Path("/data"), + workspace=Path("/workspace"), + config=None, ) - module = UndoCommandModule(plugin) - state = SimpleNamespace( - session_key="cli:1", - session=session, - msg=SimpleNamespace( - content="/undo", - channel="cli", - chat_id="1", - timestamp=datetime.now(), + _ = await root.mount( + lambda ctx: apply(ctx, None), + name="plugin_undo", + runtime=runtime, + ) + return root, commands.freeze() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("result", "kind", "text"), + ( + (None, "success", "没有可撤销"), + ( + InteractionUndoResult( + "turn:1", + "cli:1", + ("m1", "m2"), + "/backup.db", + False, + ), + "success", + "删除消息:2 条", + ), + ( + InteractionUndoResult( + "turn:1", + "cli:1", + ("m1", "m2"), + "/backup.db", + True, + ), + "error", + "等待 Core 重试", ), + ), +) +async def test_command_projects_core_result_without_private_state( + result: InteractionUndoResult | None, + kind: str, + text: str, +) -> None: + root, registry = await _registry(result) + + execution = await registry.execute( + "/undo", + session_key="cli:1", + channel="cli", + chat_id="1", + sender="user", + ) + + assert isinstance(execution, CommandExecution) + assert execution.result.kind == kind + assert text in execution.result.text + await root.dispose() + assert root.receipt().effects == () + + +def _seed_interaction(manager: SessionManager) -> tuple[str, ...]: + now = datetime.now(UTC).isoformat() + rows = manager.control_store.persist_session( + "cli:undo", + created_at=now, + updated_at=now, + metadata={}, + messages=[ + { + "role": "user", + "content": "question", + "timestamp": now, + "extra": { + "control_turn_id": "turn:undo", + "turn_input_ordinal": 0, + }, + }, + { + "role": "assistant", + "content": "answer", + "timestamp": now, + "extra": { + "control_turn_id": "turn:undo", + "turn_terminal": True, + "turn_input_count": 1, + }, + }, + ], + ) + return tuple(str(row["id"]) for row in rows) + + +@pytest.mark.asyncio +async def test_real_manager_candidate_is_inert_and_formal_command_deletes( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + sessions = SessionManager(workspace) + message_ids = _seed_interaction(sessions) + memory = _DefaultMemory() + source = Path(__file__).resolve().parents[1] + plugin_dir = tmp_path / "plugins" / "plugin_undo" + plugin_dir.mkdir(parents=True) + shutil.copy2(source / "plugin.py", plugin_dir / "plugin.py") + shutil.copy2(source / "akashic.plugin.toml", plugin_dir / "akashic.plugin.toml") + manager = PluginManager( + plugin_dirs=[tmp_path / "plugins"], + event_bus=EventBus(), + workspace=workspace, + session_manager=sessions, + memory_engine=memory, + installed_cache_root=tmp_path / "cache", ) - frame = SimpleNamespace(input=state, slots={"session:session": state.session}) - result = await module.run(frame) - assert result.slots["session:ctx"].abort is True - assert [call["dry_run"] for call in memory_engine.calls] == [True, False] - - -def test_undo_deletes_context_user_assistant_three_rows(tmp_path: Path) -> None: - manager = SessionManager(tmp_path) - session = manager.get_or_create("cli:1") - session.add_message("user", '内部') - session.add_message("user", "u0") - session.add_message("assistant", "a0") - session.add_message("user", '内部') - session.add_message("user", "u1") - session.add_message("assistant", "a1") - session.last_consolidated = 6 - manager.save(session) - target = _find_last_passive_turn(session.messages) - assert target is not None - delete_indices, _, _ = target - message_ids = [str(session.messages[i]["id"]) for i in delete_indices] - result = _run(_undo_last_turn(manager, "cli:1", expected_message_ids=message_ids)) - assert result is not None - assert result.deleted_ids == ["cli:1:3", "cli:1:4", "cli:1:5"] - - -def test_undo_keeps_cursor_when_target_after_consolidated_prefix(tmp_path: Path) -> None: - manager = SessionManager(tmp_path) - session = manager.get_or_create("cli:1") - for index in range(3): - session.add_message("user", '内部') - session.add_message("user", f"u{index}") - session.add_message("assistant", f"a{index}") - session.last_consolidated = 6 - manager.save(session) - target = _find_last_passive_turn(session.messages) - assert target is not None - delete_indices, _, _ = target - message_ids = [str(session.messages[i]["id"]) for i in delete_indices] - result = _run(_undo_last_turn(manager, "cli:1", expected_message_ids=message_ids)) - assert result is not None - assert manager.get_or_create("cli:1").last_consolidated == 6 + try: + await manager.load_all() + stable = manager.current_snapshot + assert stable is not None and stable.command_registry is not None + + plugin_path = plugin_dir / "plugin.py" + plugin_path.write_text( + plugin_path.read_text(encoding="utf-8").replace( + 'version = "2.0.0"', + 'version = "2.0.1"', + ), + encoding="utf-8", + ) + manifest_path = plugin_dir / "akashic.plugin.toml" + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8").replace( + 'version = "2.0.0"', + 'version = "2.0.1"', + ), + encoding="utf-8", + ) + candidate = await manager.prepare_candidate("plugin_undo") + assert candidate is not None + sessions.invalidate("cli:undo") + assert tuple( + str(row["id"]) for row in sessions.get_existing("cli:undo").messages + ) == message_ids + assert memory.calls == [] + + published = await manager.publish_prepared("plugin_undo") + assert published["publication_state"] == "committed" + current = manager.current_snapshot + assert current is not None and current.command_registry is not None + execution = await current.command_registry.execute( + "/undo", + session_key="cli:undo", + channel="cli", + chat_id="undo", + sender="user", + ) + assert execution is not None + assert execution.result.kind == "success" + assert execution.result.text == "已撤销上一轮对话。\n删除消息:2 条" + assert memory.calls == [message_ids] + assert sessions.get_existing("cli:undo").messages == [] + root = current.composition_root + assert root is not None + await manager.terminate_all() + assert root.receipt().effects == () + assert root.receipt().services == () + finally: + sessions.close() From 922d69c40f8af1afe7d0cfcf573153a396654400 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 21:42:24 +0800 Subject: [PATCH 2/6] feat(undo): expose recovery receipt --- plugin.py | 6 ++++++ tests/test_plugin.py | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/plugin.py b/plugin.py index 69d824b..7ed7380 100644 --- a/plugin.py +++ b/plugin.py @@ -30,6 +30,9 @@ async def handle(invocation: CommandInvocation) -> CommandResult: text=( "上一轮对话已撤销,但派生记忆仍在等待 Core 重试收敛。" f"\n删除消息:{len(result.message_ids)} 条" + f"\n压缩游标:{result.old_last_consolidated}" + f" → {result.new_last_consolidated}" + f"\n恢复备份:{result.backup_path}" ), ) return CommandResult( @@ -37,6 +40,9 @@ async def handle(invocation: CommandInvocation) -> CommandResult: text=( "已撤销上一轮对话。" f"\n删除消息:{len(result.message_ids)} 条" + f"\n压缩游标:{result.old_last_consolidated}" + f" → {result.new_last_consolidated}" + f"\n恢复备份:{result.backup_path}" ), ) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index b0ea27e..487014c 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -216,7 +216,14 @@ async def test_real_manager_candidate_is_inert_and_formal_command_deletes( ) assert execution is not None assert execution.result.kind == "success" - assert execution.result.text == "已撤销上一轮对话。\n删除消息:2 条" + assert execution.result.text.startswith( + "已撤销上一轮对话。" + "\n删除消息:2 条" + "\n压缩游标:0 → 0" + "\n恢复备份:" + ) + backup_path = Path(execution.result.text.rsplit(":", 1)[1]) + assert backup_path.is_file() assert memory.calls == [message_ids] assert sessions.get_existing("cli:undo").messages == [] root = current.composition_root From 7b0e4cdd37a389fca889df0300fa94662fb9c8b7 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 21:46:55 +0800 Subject: [PATCH 3/6] test(undo): require cursor receipt --- tests/test_plugin.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 487014c..61aa850 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -82,6 +82,8 @@ async def undo_latest(_session_key: str) -> InteractionUndoResult | None: ("m1", "m2"), "/backup.db", False, + 3, + 1, ), "success", "删除消息:2 条", @@ -93,6 +95,8 @@ async def undo_latest(_session_key: str) -> InteractionUndoResult | None: ("m1", "m2"), "/backup.db", True, + 3, + 1, ), "error", "等待 Core 重试", From 276b6c7bafaa234b0abb7be6cdd2c5ef3c754e91 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:12:59 +0800 Subject: [PATCH 4/6] ci: pin pure v3 core --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 5463282..085f551 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 189c25a3e011c90cc8106fdda0b57c8c0ae71730 + ref: 3005f838bcd96e2cbc58616aede46e4f39df4523 path: .akashic-core - uses: actions/setup-python@v5 with: From 49fb1f14367489e312f26341f137eb122faeb891 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:19:30 +0800 Subject: [PATCH 5/6] ci(plugin): install the declared type checker --- .github/workflows/plugin-api-v3.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 085f551..e29f92f 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -46,7 +46,10 @@ jobs: - name: Install exact Core runtime run: | python -m venv .venv - .venv/bin/python -m pip install -r .akashic-core/requirements.txt pytest pytest-asyncio + .venv/bin/python -m pip install \ + -r .akashic-core/requirements.txt \ + -r .akashic-core/requirements-dev.txt \ + pytest pytest-asyncio - name: Verify Plugin Undo v3 composition env: AKASHIC_AGENT_ROOT: ${{ github.workspace }}/.akashic-core From 86941208ea9313086c1c5d8f33b38cf4432e599d Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:22:19 +0800 Subject: [PATCH 6/6] ci(plugin): bind type checks to the v3 runtime --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index e29f92f..eb057f4 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -58,7 +58,7 @@ jobs: - name: Check v3 source types env: PYTHONPATH: .akashic-core - run: .venv/bin/pyright --level error plugin.py + run: .venv/bin/pyright --pythonpath .venv/bin/python --level error plugin.py - name: Compile Python sources run: python -m compileall -q . - name: Check diff formatting