mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 06:58:51 +08:00
fix: separate reset and new conversation semantics
This commit is contained in:
@@ -107,6 +107,39 @@ class ConversationCommands:
|
||||
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 @@ class ConversationCommands:
|
||||
"""
|
||||
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,
|
||||
|
||||
@@ -44,12 +44,8 @@ class Main(star.Star):
|
||||
@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 @@ class Main(star.Star):
|
||||
@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")
|
||||
|
||||
+10
-10
@@ -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:
|
||||
|
||||
|
||||
+10
-10
@@ -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。
|
||||
|
||||
权限说明:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user