fix: separate reset and new conversation semantics (#10118)

* fix: separate reset and new conversation semantics

* Revert "fix: separate reset and new conversation semantics"

This reverts commit e24f5ffb04.

* Reapply "fix: separate reset and new conversation semantics"

This reverts commit 3fa0477afb.
This commit is contained in:
w31rd
2026-09-21 16:22:39 +08:00
committed by GitHub
parent 8b5e24ba2e
commit 3ada8aa0ae
5 changed files with 255 additions and 59 deletions
@@ -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,
+3 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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。
权限说明:
+198 -22
View File
@@ -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