From e24f5ffb04b8e9e78945ddd65dd5df193062574a Mon Sep 17 00:00:00 2001 From: zenfun Date: Thu, 17 Sep 2026 17:30:04 +0800 Subject: [PATCH 1/3] fix: separate reset and new conversation semantics --- .../builtin_commands/commands/conversation.py | 40 +++- .../builtin_stars/builtin_commands/main.py | 14 +- docs/en/use/command.md | 20 +- docs/zh/use/command.md | 20 +- tests/unit/test_conversation_restart.py | 220 ++++++++++++++++-- 5 files changed, 255 insertions(+), 59 deletions(-) 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 From 3fa0477afb1f8908cb8c842c054ec2e5a5b387d4 Mon Sep 17 00:00:00 2001 From: zenfun Date: Thu, 17 Sep 2026 18:10:01 +0800 Subject: [PATCH 2/3] Revert "fix: separate reset and new conversation semantics" This reverts commit e24f5ffb04b8e9e78945ddd65dd5df193062574a. --- .../builtin_commands/commands/conversation.py | 40 +--- .../builtin_stars/builtin_commands/main.py | 14 +- docs/en/use/command.md | 20 +- docs/zh/use/command.md | 20 +- tests/unit/test_conversation_restart.py | 220 ++---------------- 5 files changed, 59 insertions(+), 255 deletions(-) diff --git a/astrbot/builtin_stars/builtin_commands/commands/conversation.py b/astrbot/builtin_stars/builtin_commands/commands/conversation.py index 3d3edb911b..5eb02bfe2b 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/conversation.py +++ b/astrbot/builtin_stars/builtin_commands/commands/conversation.py @@ -107,39 +107,6 @@ 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) @@ -174,14 +141,19 @@ 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 6765e78cff..7951e919ab 100644 --- a/astrbot/builtin_stars/builtin_commands/main.py +++ b/astrbot/builtin_stars/builtin_commands/main.py @@ -44,8 +44,12 @@ 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: - """Clear the context of the current conversation.""" - await self.conversation_c.reset(message) + """Start a new conversation, keeping previous history. + + Args: + message: Command event identifying the session and sender. + """ + await self.conversation_c.new_conv(message) @filter.command("stop") async def stop(self, message: AstrMessageEvent) -> None: @@ -55,7 +59,11 @@ 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: - """Create a new conversation.""" + """Start a new conversation, keeping previous history. + + Args: + message: Command event identifying the session and sender. + """ 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 6207f40606..2c8a2bd39b 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`: Clear the context of the current conversation. +- `/reset`: Create and switch to a new conversation, just like `/new`. - `/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` -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: +`/reset` and `/new` use the same restart flow. Both command entries and their individual command management settings are retained. -`/reset`: +For AstrBot's built-in Agent Runner, it: -- 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. +- 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. -`/new`: +For third-party Agent Runners such as `dify`, `coze`, `dashscope`, and `deerflow`, it: -- 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. +- 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. -DeerFlow also attempts to delete the old remote thread. +DeerFlow also attempts to delete the old remote thread. Third-party runners do not guarantee retention of previous history. Permission notes: diff --git a/docs/zh/use/command.md b/docs/zh/use/command.md index 04df68dc25..1bb6d81d85 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`:清空当前对话的上下文。 +- `/reset`:与 `/new` 一样,创建并切换到新对话。 - `/stop`:停止当前会话中正在运行的 Agent 任务。 - `/new`:创建并切换到一个新对话。 - `/stats`:查看当前会话的 Token 用量统计。 @@ -67,20 +67,20 @@ AstrBot 的指令通过插件机制注册。为了保持主程序轻量,当前 ### `/reset` 和 `/new` -两条指令都会停止当前会话中正在运行的任务,并在回复发送后清理当前会话的群聊临时上下文。它们的对话处理方式不同: +`/reset` 和 `/new` 执行相同的新建对话流程,保留两个指令入口及各自的指令管理设置。 -`/reset` 会: +对于 AstrBot 内置 Agent Runner,它会: -- 对 AstrBot 内置 Agent Runner,清空当前对话的上下文消息,但保留对话 ID、标题、Persona 和 Token 用量统计。 -- 对第三方 Agent Runner,例如 `dify`、`coze`、`dashscope`、`deerflow`,清理远端 runner 上下文,但保留本地对话 ID。 -- 当前没有对话时直接返回成功提示,不会创建新的本地对话。 +- 标记当前会话中其他活跃事件停止,不等待所有任务退出。 +- 创建并切换到空的新对话,保留旧对话历史并继承当前人格。 +- 在回复发送后清理当前会话的群聊上下文缓存。 -`/new` 会: +对于第三方 Agent Runner,例如 `dify`、`coze`、`dashscope`、`deerflow`,它会: -- 对 AstrBot 内置 Agent Runner,保留旧对话记录,创建并切换到新的本地对话,并继承当前 Persona。 -- 对第三方 Agent Runner,先清理远端 runner 上下文,再创建并切换到新的本地对话;旧的本地对话记录仍然保留。 +- 停止当前会话中正在运行的任务。 +- 删除当前会话保存的第三方会话 ID,让下一轮对话重新开始。 -DeerFlow 还会尝试删除旧的远端 thread。 +DeerFlow 还会尝试删除旧的远端 thread;第三方路径不保证保留旧记录。 权限说明: diff --git a/tests/unit/test_conversation_restart.py b/tests/unit/test_conversation_restart.py index def8096d98..0af56b8932 100644 --- a/tests/unit/test_conversation_restart.py +++ b/tests/unit/test_conversation_restart.py @@ -122,30 +122,21 @@ 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 ) - 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() + restart.manager.new_conversation.assert_awaited_once_with( + restart.event.unified_msg_origin, + "qq", + persona_id="persona", + ) assert restart.extras["_clean_group_context_session"] is True @@ -231,24 +222,13 @@ 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) - 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() + restart.manager.new_conversation.assert_awaited_once_with( + event.unified_msg_origin, "qq", persona_id="persona" + ) else: restart.manager.new_conversation.assert_not_awaited() - restart.manager.update_conversation.assert_not_awaited() @pytest.mark.asyncio @@ -257,39 +237,16 @@ 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.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=None, ) - 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_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): +async def test_restart_order_and_creation_failure(restart): calls = [] restart.stop.side_effect = lambda *a, **kw: calls.append("stop") restart.manager.get_curr_conversation_id.side_effect = lambda *a: ( @@ -297,13 +254,13 @@ async def test_reset_order_and_clearing_failure(restart): ) async def fail(*args, **kwargs): - calls.append("clear") + calls.append("create") raise RuntimeError("database unavailable") - restart.manager.update_conversation.side_effect = fail + restart.manager.new_conversation.side_effect = fail with pytest.raises(RuntimeError, match="database unavailable"): await restart.plugin.reset(restart.event) - assert calls == ["stop", "read", "clear"] + assert calls == ["stop", "read", "create"] restart.event.set_result.assert_not_called() assert not restart.extras @@ -324,20 +281,12 @@ 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_new_preserves_history_and_late_writes(restart, temp_db, monkeypatch): +async def test_restart_preserves_history_and_late_writes(restart, temp_db, monkeypatch): await temp_db.initialize() selections = {} monkeypatch.setattr( @@ -365,7 +314,7 @@ async def test_new_preserves_history_and_late_writes(restart, temp_db, monkeypat old_id = await manager.new_conversation( umo, "qq", content=history, persona_id="persona" ) - await restart.plugin.new_conv(restart.event) + await restart.plugin.reset(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 @@ -383,113 +332,7 @@ async def test_new_preserves_history_and_late_writes(restart, temp_db, monkeypat @pytest.mark.asyncio -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): +async def test_restart_cleans_only_target_group_cache(restart): cache = GroupChatContext(MagicMock(), restart.context) target = restart.event.unified_msg_origin other = "qq:GroupMessage:another-member_group" @@ -498,24 +341,13 @@ async def test_restart_cleans_only_target_group_cache(restart, entry): core = CorePlugin.__new__(CorePlugin) core.group_chat_context = cache core.group_context_enabled = lambda event: True - await getattr(restart.plugin, entry)(restart.event) + await restart.plugin.reset(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) @@ -582,11 +414,3 @@ 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 From 11540df1fe4db2582de936f7f519bebafd030b11 Mon Sep 17 00:00:00 2001 From: zenfun Date: Sat, 19 Sep 2026 14:59:33 +0800 Subject: [PATCH 3/3] Reapply "fix: separate reset and new conversation semantics" This reverts commit 3fa0477afb1f8908cb8c842c054ec2e5a5b387d4. --- .../builtin_commands/commands/conversation.py | 40 +++- .../builtin_stars/builtin_commands/main.py | 14 +- docs/en/use/command.md | 20 +- docs/zh/use/command.md | 20 +- tests/unit/test_conversation_restart.py | 220 ++++++++++++++++-- 5 files changed, 255 insertions(+), 59 deletions(-) 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