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