diff --git a/astrbot/builtin_stars/builtin_commands/commands/conversation.py b/astrbot/builtin_stars/builtin_commands/commands/conversation.py index 5eb02bfe2b..3d3edb911b 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/conversation.py +++ b/astrbot/builtin_stars/builtin_commands/commands/conversation.py @@ -107,6 +107,39 @@ async def _get_current_persona_id(self, session_id): return None return conv.persona_id + async def reset(self, message: AstrMessageEvent) -> None: + """Clear the context of the current conversation. + + Args: + message: Command event identifying the session and sender. + """ + umo = message.unified_msg_origin + cfg = self.context.get_config(umo=umo) + agent_runner_type = cfg["agent_runner"]["runner_type"] + + active_event_registry.stop_all(umo, exclude=message) + cid = await self.context.conversation_manager.get_curr_conversation_id(umo) + if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + await _clear_third_party_agent_runner_state( + self.context, + umo, + agent_runner_type, + ) + else: + if cid: + await self.context.conversation_manager.update_conversation( + umo, + cid, + history=[], + ) + + message.set_extra("_clean_group_context_session", True) + message.set_result( + MessageEventResult().message( + "✅ The current conversation context has been cleared." + ) + ) + async def stop(self, message: AstrMessageEvent) -> None: """停止当前会话正在运行的 Agent""" cfg = self.context.get_config(umo=message.unified_msg_origin) @@ -141,19 +174,14 @@ async def new_conv(self, message: AstrMessageEvent) -> None: """ cfg = self.context.get_config(umo=message.unified_msg_origin) agent_runner_type = cfg["agent_runner"]["runner_type"] + active_event_registry.stop_all(message.unified_msg_origin, exclude=message) if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: - active_event_registry.stop_all(message.unified_msg_origin, exclude=message) await _clear_third_party_agent_runner_state( self.context, message.unified_msg_origin, agent_runner_type, ) - message.set_result( - MessageEventResult().message("✅ New conversation created.") - ) - return - active_event_registry.stop_all(message.unified_msg_origin, exclude=message) cpersona = await self._get_current_persona_id(message.unified_msg_origin) cid = await self.context.conversation_manager.new_conversation( message.unified_msg_origin, diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py index 7951e919ab..6765e78cff 100644 --- a/astrbot/builtin_stars/builtin_commands/main.py +++ b/astrbot/builtin_stars/builtin_commands/main.py @@ -44,12 +44,8 @@ async def name(self, event: AstrMessageEvent, alias: GreedyStr) -> None: @filter.command("reset") @filter.permission_type(filter.PermissionType.SHARED_GROUP_ADMIN) async def reset(self, message: AstrMessageEvent) -> None: - """Start a new conversation, keeping previous history. - - Args: - message: Command event identifying the session and sender. - """ - await self.conversation_c.new_conv(message) + """Clear the context of the current conversation.""" + await self.conversation_c.reset(message) @filter.command("stop") async def stop(self, message: AstrMessageEvent) -> None: @@ -59,11 +55,7 @@ async def stop(self, message: AstrMessageEvent) -> None: @filter.command("new") @filter.permission_type(filter.PermissionType.SHARED_GROUP_ADMIN) async def new_conv(self, message: AstrMessageEvent) -> None: - """Start a new conversation, keeping previous history. - - Args: - message: Command event identifying the session and sender. - """ + """Create a new conversation.""" await self.conversation_c.new_conv(message) @filter.command("stats") diff --git a/docs/en/use/command.md b/docs/en/use/command.md index 2c8a2bd39b..6207f40606 100644 --- a/docs/en/use/command.md +++ b/docs/en/use/command.md @@ -15,7 +15,7 @@ The following commands are shipped with AstrBot and loaded by default: - `/help`: View currently enabled commands and AstrBot version information. - `/sid`: View current message source information, including UMO, user ID, platform ID, message type, and session ID. This is commonly used when configuring admins, allowlists, or routing rules. - `/name`: Set a display alias for the current UMO, which means one concrete group or private-chat message source on a platform, so it is easier to recognize in WebUI. This command requires admin permission. -- `/reset`: Create and switch to a new conversation, just like `/new`. +- `/reset`: Clear the context of the current conversation. - `/stop`: Stop Agent tasks currently running in the current session. - `/new`: Create and switch to a new conversation. - `/stats`: View token usage statistics for the current conversation. @@ -73,20 +73,20 @@ Display rules: ### `/reset` and `/new` -`/reset` and `/new` use the same restart flow. Both command entries and their individual command management settings are retained. +Both commands stop running tasks in the current session and clear the session's temporary group context after the reply is sent. Their conversation behavior is different: -For AstrBot's built-in Agent Runner, it: +`/reset`: -- Marks other active events in the current session as stopped, without waiting for every task to exit. -- Creates and selects an empty conversation, preserving previous history and inheriting the current persona. -- Clears the current session's group context cache after the reply is sent. +- For AstrBot's built-in Agent Runner, clears the current conversation's context messages while preserving its conversation ID, title, persona, and token usage statistics. +- For third-party Agent Runners such as `dify`, `coze`, `dashscope`, and `deerflow`, clears the remote runner context while preserving the local conversation ID. +- If there is no current conversation, returns a success message without creating a local conversation. -For third-party Agent Runners such as `dify`, `coze`, `dashscope`, and `deerflow`, it: +`/new`: -- Stops running tasks in the current session. -- Removes the saved third-party conversation ID for this session, so the next turn starts a new conversation. +- For AstrBot's built-in Agent Runner, preserves the old conversation record, creates and selects a new local conversation, and inherits the current persona. +- For third-party Agent Runners, clears the remote runner context first, then creates and selects a new local conversation; old local conversation records remain available. -DeerFlow also attempts to delete the old remote thread. Third-party runners do not guarantee retention of previous history. +DeerFlow also attempts to delete the old remote thread. Permission notes: diff --git a/docs/zh/use/command.md b/docs/zh/use/command.md index 1bb6d81d85..04df68dc25 100644 --- a/docs/zh/use/command.md +++ b/docs/zh/use/command.md @@ -15,7 +15,7 @@ AstrBot 的指令通过插件机制注册。为了保持主程序轻量,当前 - `/help`:查看当前启用的指令和 AstrBot 版本信息。 - `/sid`:查看当前消息来源信息,包括 UMO、用户 ID、平台 ID、消息类型和会话 ID。常用于配置管理员、白名单或路由规则。 - `/name`:为当前 UMO(统一消息来源,即某个平台上的一个群聊或私聊会话)设置展示别名,让 WebUI 中的会话来源更容易识别。该指令需要管理员权限。 -- `/reset`:与 `/new` 一样,创建并切换到新对话。 +- `/reset`:清空当前对话的上下文。 - `/stop`:停止当前会话中正在运行的 Agent 任务。 - `/new`:创建并切换到一个新对话。 - `/stats`:查看当前会话的 Token 用量统计。 @@ -67,20 +67,20 @@ AstrBot 的指令通过插件机制注册。为了保持主程序轻量,当前 ### `/reset` 和 `/new` -`/reset` 和 `/new` 执行相同的新建对话流程,保留两个指令入口及各自的指令管理设置。 +两条指令都会停止当前会话中正在运行的任务,并在回复发送后清理当前会话的群聊临时上下文。它们的对话处理方式不同: -对于 AstrBot 内置 Agent Runner,它会: +`/reset` 会: -- 标记当前会话中其他活跃事件停止,不等待所有任务退出。 -- 创建并切换到空的新对话,保留旧对话历史并继承当前人格。 -- 在回复发送后清理当前会话的群聊上下文缓存。 +- 对 AstrBot 内置 Agent Runner,清空当前对话的上下文消息,但保留对话 ID、标题、Persona 和 Token 用量统计。 +- 对第三方 Agent Runner,例如 `dify`、`coze`、`dashscope`、`deerflow`,清理远端 runner 上下文,但保留本地对话 ID。 +- 当前没有对话时直接返回成功提示,不会创建新的本地对话。 -对于第三方 Agent Runner,例如 `dify`、`coze`、`dashscope`、`deerflow`,它会: +`/new` 会: -- 停止当前会话中正在运行的任务。 -- 删除当前会话保存的第三方会话 ID,让下一轮对话重新开始。 +- 对 AstrBot 内置 Agent Runner,保留旧对话记录,创建并切换到新的本地对话,并继承当前 Persona。 +- 对第三方 Agent Runner,先清理远端 runner 上下文,再创建并切换到新的本地对话;旧的本地对话记录仍然保留。 -DeerFlow 还会尝试删除旧的远端 thread;第三方路径不保证保留旧记录。 +DeerFlow 还会尝试删除旧的远端 thread。 权限说明: diff --git a/tests/unit/test_conversation_restart.py b/tests/unit/test_conversation_restart.py index 0af56b8932..def8096d98 100644 --- a/tests/unit/test_conversation_restart.py +++ b/tests/unit/test_conversation_restart.py @@ -122,21 +122,30 @@ async def test_restart_permission_matrix( assert allowed == (admin or not group or isolated) if allowed: await handler.handler(restart.plugin, restart.event) - restart.manager.update_conversation.assert_not_awaited() if not allowed: restart.stop.assert_not_called() restart.manager.get_curr_conversation_id.assert_not_awaited() restart.manager.new_conversation.assert_not_awaited() + restart.manager.update_conversation.assert_not_awaited() assert restart.extras == {"_session_isolated": isolated} else: restart.stop.assert_called_once_with( restart.event.unified_msg_origin, exclude=restart.event ) - restart.manager.new_conversation.assert_awaited_once_with( - restart.event.unified_msg_origin, - "qq", - persona_id="persona", - ) + if entry == "reset": + restart.manager.update_conversation.assert_awaited_once_with( + restart.event.unified_msg_origin, + "old-id", + history=[], + ) + restart.manager.new_conversation.assert_not_awaited() + else: + restart.manager.new_conversation.assert_awaited_once_with( + restart.event.unified_msg_origin, + "qq", + persona_id="persona", + ) + restart.manager.update_conversation.assert_not_awaited() assert restart.extras["_clean_group_context_session"] is True @@ -222,13 +231,24 @@ async def test_restart_permissions_follow_pipeline_isolation( ) assert permission.permission_type == original_permission restart.manager.new_conversation.reset_mock() + restart.manager.update_conversation.reset_mock() if allowed: await handler.handler(restart.plugin, event) - restart.manager.new_conversation.assert_awaited_once_with( - event.unified_msg_origin, "qq", persona_id="persona" - ) + if entry == "reset": + restart.manager.update_conversation.assert_awaited_once_with( + event.unified_msg_origin, + "old-id", + history=[], + ) + restart.manager.new_conversation.assert_not_awaited() + else: + restart.manager.new_conversation.assert_awaited_once_with( + event.unified_msg_origin, "qq", persona_id="persona" + ) + restart.manager.update_conversation.assert_not_awaited() else: restart.manager.new_conversation.assert_not_awaited() + restart.manager.update_conversation.assert_not_awaited() @pytest.mark.asyncio @@ -237,16 +257,39 @@ async def test_restart_without_provider_or_current_conversation(restart, entry): restart.manager.get_curr_conversation_id.return_value = None restart.context.get_using_provider_async = AsyncMock(return_value=None) await getattr(restart.plugin, entry)(restart.event) - restart.manager.new_conversation.assert_awaited_once_with( - restart.event.unified_msg_origin, - "qq", - persona_id=None, + restart.stop.assert_called_once_with( + restart.event.unified_msg_origin, exclude=restart.event ) + if entry == "new_conv": + restart.manager.new_conversation.assert_awaited_once_with( + restart.event.unified_msg_origin, + "qq", + persona_id=None, + ) + restart.manager.update_conversation.assert_not_awaited() + else: + restart.manager.new_conversation.assert_not_awaited() + restart.manager.update_conversation.assert_not_awaited() + result = restart.event.set_result.call_args.args[0] + assert result.get_plain_text() == ( + "✅ The current conversation context has been cleared." + ) + assert restart.extras["_clean_group_context_session"] is True restart.context.get_using_provider_async.assert_not_awaited() @pytest.mark.asyncio -async def test_restart_order_and_creation_failure(restart): +async def test_reset_reports_context_cleared(restart): + await restart.plugin.reset(restart.event) + + result = restart.event.set_result.call_args.args[0] + assert result.get_plain_text() == ( + "✅ The current conversation context has been cleared." + ) + + +@pytest.mark.asyncio +async def test_reset_order_and_clearing_failure(restart): calls = [] restart.stop.side_effect = lambda *a, **kw: calls.append("stop") restart.manager.get_curr_conversation_id.side_effect = lambda *a: ( @@ -254,13 +297,13 @@ async def test_restart_order_and_creation_failure(restart): ) async def fail(*args, **kwargs): - calls.append("create") + calls.append("clear") raise RuntimeError("database unavailable") - restart.manager.new_conversation.side_effect = fail + restart.manager.update_conversation.side_effect = fail with pytest.raises(RuntimeError, match="database unavailable"): await restart.plugin.reset(restart.event) - assert calls == ["stop", "read", "create"] + assert calls == ["stop", "read", "clear"] restart.event.set_result.assert_not_called() assert not restart.extras @@ -281,12 +324,20 @@ async def test_external_runner_restart(restart, monkeypatch, entry, runner): key=commands.THIRD_PARTY_AGENT_RUNNER_KEY[runner], ) assert cleanup.await_count == (runner == commands.DEERFLOW_PROVIDER_TYPE) - restart.manager.new_conversation.assert_not_awaited() restart.manager.update_conversation.assert_not_awaited() + if entry == "new_conv": + restart.manager.new_conversation.assert_awaited_once_with( + restart.event.unified_msg_origin, + "qq", + persona_id="persona", + ) + else: + restart.manager.new_conversation.assert_not_awaited() + assert restart.extras["_clean_group_context_session"] is True @pytest.mark.asyncio -async def test_restart_preserves_history_and_late_writes(restart, temp_db, monkeypatch): +async def test_new_preserves_history_and_late_writes(restart, temp_db, monkeypatch): await temp_db.initialize() selections = {} monkeypatch.setattr( @@ -314,7 +365,7 @@ async def test_restart_preserves_history_and_late_writes(restart, temp_db, monke old_id = await manager.new_conversation( umo, "qq", content=history, persona_id="persona" ) - await restart.plugin.reset(restart.event) + await restart.plugin.new_conv(restart.event) new_id = await manager.get_curr_conversation_id(umo) assert new_id != old_id assert json.loads((await manager.get_conversation(umo, old_id)).history) == history @@ -332,7 +383,113 @@ async def test_restart_preserves_history_and_late_writes(restart, temp_db, monke @pytest.mark.asyncio -async def test_restart_cleans_only_target_group_cache(restart): +async def test_reset_clears_history_but_preserves_conversation_metadata( + restart, temp_db, monkeypatch +): + await temp_db.initialize() + selections = {} + monkeypatch.setattr( + conversation_mgr.sp, + "session_put", + AsyncMock( + side_effect=lambda umo, key, value: selections.__setitem__( + (umo, key), value + ), + ), + ) + monkeypatch.setattr( + conversation_mgr.sp, + "session_get", + AsyncMock( + side_effect=lambda umo, key, default=None: selections.get( + (umo, key), default + ), + ), + ) + manager = conversation_mgr.ConversationManager(temp_db) + restart.context.conversation_manager = manager + umo = restart.event.unified_msg_origin + history = [{"role": "user", "content": "Clear this history"}] + old_id = await manager.new_conversation( + umo, + "qq", + content=history, + title="Keep this title", + persona_id="persona", + ) + await temp_db.update_conversation(cid=old_id, token_usage=42) + + await restart.plugin.reset(restart.event) + + assert await manager.get_curr_conversation_id(umo) == old_id + conversation = await manager.get_conversation(umo, old_id) + assert conversation is not None + assert json.loads(conversation.history) == [] + assert conversation.title == "Keep this title" + assert conversation.persona_id == "persona" + assert conversation.token_usage == 42 + + +@pytest.mark.asyncio +async def test_external_new_creates_local_conversation_and_keeps_old_record( + restart, temp_db, monkeypatch +): + await temp_db.initialize() + selections = {} + monkeypatch.setattr( + conversation_mgr.sp, + "session_put", + AsyncMock( + side_effect=lambda umo, key, value: selections.__setitem__( + (umo, key), value + ), + ), + ) + monkeypatch.setattr( + conversation_mgr.sp, + "session_get", + AsyncMock( + side_effect=lambda umo, key, default=None: selections.get( + (umo, key), default + ), + ), + ) + remove = AsyncMock() + monkeypatch.setattr(commands.sp, "remove_async", remove) + manager = conversation_mgr.ConversationManager(temp_db) + restart.context.conversation_manager = manager + restart.config["agent_runner"]["runner_type"] = "dify" + umo = restart.event.unified_msg_origin + history = [{"role": "user", "content": "Keep this record"}] + old_id = await manager.new_conversation( + umo, + "qq", + content=history, + title="Old conversation", + persona_id="persona", + ) + + await restart.plugin.new_conv(restart.event) + + new_id = await manager.get_curr_conversation_id(umo) + assert new_id != old_id + remove.assert_awaited_once_with( + scope="umo", + scope_id=umo, + key="dify_conversation_id", + ) + old = await manager.get_conversation(umo, old_id) + new = await manager.get_conversation(umo, new_id) + assert old is not None and new is not None + assert json.loads(old.history) == history + assert old.title == "Old conversation" + assert new.persona_id == "persona" + assert json.loads(new.history) == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("entry", ["reset", "new_conv"]) +async def test_restart_cleans_only_target_group_cache(restart, entry): cache = GroupChatContext(MagicMock(), restart.context) target = restart.event.unified_msg_origin other = "qq:GroupMessage:another-member_group" @@ -341,13 +498,24 @@ async def test_restart_cleans_only_target_group_cache(restart): core = CorePlugin.__new__(CorePlugin) core.group_chat_context = cache core.group_context_enabled = lambda event: True - await restart.plugin.reset(restart.event) + await getattr(restart.plugin, entry)(restart.event) assert target in cache.raw_records await core.after_message_sent(restart.event) assert target not in cache.raw_records assert list(cache.raw_records[other]) == ["other context"] +@pytest.mark.parametrize( + ("entry", "description"), + [ + ("reset", "Clear the context of the current conversation."), + ("new_conv", "Create a new conversation."), + ], +) +def test_restart_command_descriptions(restart_handlers, entry, description): + assert restart_handlers[entry].desc == description + + def test_config_preserves_isolation(tmp_path): path = tmp_path / "config.json" config = copy.deepcopy(DEFAULT_CONFIG) @@ -414,3 +582,11 @@ def test_restart_docs_use_current_permission_labels(locale, language): assert "allow_member_new_conversation" not in guide assert "Allow Non-Administrators to Start Group Conversations" not in guide assert "允许非管理员在群聊中新建对话" not in guide + if language == "zh": + assert "清空当前对话的上下文" in guide + assert "创建并切换到一个新对话" in guide + assert "执行相同的新建对话流程" not in guide + else: + assert "Clear the context of the current conversation." in guide + assert "Create and switch to a new conversation." in guide + assert "use the same restart flow" not in guide