Skip to content

Comments

fix: chatui cannot persiste file segment#5383

Closed
Soulter wants to merge 4 commits intomasterfrom
feat/stop-agent
Closed

fix: chatui cannot persiste file segment#5383
Soulter wants to merge 4 commits intomasterfrom
feat/stop-agent

Conversation

@Soulter
Copy link
Member

@Soulter Soulter commented Feb 23, 2026

  • feat: add stop functionality for active agent sessions and improve handling of stop requests
  • feat: update stop button icon and tooltip in ChatInput component
  • fix: correct indentation in tool call handling within ChatRoute class
  • fix: chatui cannot persiste file segment

Modifications / 改动点

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果


Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

Summary by Sourcery

为运行中的 agent 会话添加用户主动停止支持,同时保留部分输出,并修复 Web 聊天中的文件附件持久化问题。

New Features:

  • 允许用户通过聊天界面中的停止按钮、HTTP 接口和内置停止命令来停止活跃的 agent 运行。
  • 在 agent runner 中支持协作式停止信号,以便将 LLM 的部分输出最终化并作为“已中止”的响应呈现出来。

Bug Fixes:

  • 确保 Web 聊天中的文件和图片附件既能从新的附件目录,也能从旧的图片目录中解析,从而保证现有分段能够正确持久化。
  • 避免在聊天 UI 日志中将流中止和读取取消视为通用的发送/流式错误。

Enhancements:

  • 当用户停止一个 agent 时,将已中止的运行持久化到会话历史中,包括在可用时保留部分助手消息。
  • 扩展活跃事件注册表,使其可以请求停止 agent 而无需取消整个事件,从而允许后续的处理(如历史保存)继续进行。
  • 更新聊天输入控件,在会话运行期间显示停止图标和工具提示。

Tests:

  • 添加单元测试,验证停止信号会产生“已中止”的 agent 响应,并且部分助手消息能够被保留。
Original summary in English

Summary by Sourcery

Add user-initiated stop support for running agent sessions while preserving partial outputs and fix file attachment persistence in web chat.

New Features:

  • Allow users to stop active agent runs via chat UI stop button, HTTP endpoint, and built-in stop command.
  • Support cooperative stop signals in the agent runner so partial LLM outputs are finalized and surfaced as aborted responses.

Bug Fixes:

  • Ensure web chat file and image attachments are resolved from both the new attachments directory and legacy image directory so existing segments persist correctly.
  • Avoid treating stream aborts and cancelled reads as generic send/streaming errors in the chat UI logs.

Enhancements:

  • Persist aborted runs into conversation history when the user stops an agent, including partial assistant messages when available.
  • Extend active event registry to request agent stop without cancelling the whole event, allowing downstream processing like history saving to continue.
  • Update chat input controls to show a stop icon and tooltip while a conversation is running.

Tests:

  • Add a unit test verifying that stop signals produce an aborted agent response and that partial assistant messages are preserved.

@dosubot dosubot bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Feb 23, 2026
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @Soulter, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances user control over active agent sessions by introducing a robust stop mechanism. Users can now interrupt ongoing agent responses via a new command or a dedicated UI button, with the backend ensuring graceful termination and preservation of any partial outputs. Additionally, it resolves a critical issue with file segment persistence in the chat interface by standardizing attachment storage, improving the reliability of multimedia content in conversations.

Highlights

  • Agent Session Stop Functionality: Introduced a new /stop command and corresponding backend logic to allow users to terminate active agent sessions gracefully, preserving partial outputs.
  • Chat UI Stop Button Integration: Added a 'Stop generating' button to the chat input in the frontend, which appears when an agent session is active, enabling users to interrupt ongoing responses.
  • Improved File Segment Persistence: Refactored the storage mechanism for chat attachments (images, records, files) to a standardized attachments_dir, resolving an issue where file segments were not persisting correctly in the ChatUI.
  • Enhanced Agent Stop Request Handling: Implemented detailed internal flags and utility functions within the agent runner to manage stop signals, ensuring that agent execution can be interrupted and partial LLM responses are correctly handled and saved.
  • New Backend API for Session Control: Added a /chat/stop API endpoint to the dashboard's chat route, allowing the frontend to send requests to stop active agent sessions for a given user and session ID.
Changelog
  • astrbot/builtin_stars/builtin_commands/commands/conversation.py
    • Added an asynchronous stop method to handle agent session termination.
  • astrbot/builtin_stars/builtin_commands/main.py
    • Registered the new stop command for agent session control.
  • astrbot/core/agent/runners/tool_loop_agent_runner.py
    • Implemented internal flags (_stop_requested, _aborted) and logic to manage and respond to agent stop requests during execution, preserving partial LLM responses.
    • Added request_stop and was_aborted methods for external control and status checking.
  • astrbot/core/astr_agent_run_util.py
    • Introduced utility functions (_should_stop_agent, _watch_agent_stop_signal) to monitor and propagate agent stop signals, integrating with the agent runner's stop mechanism.
  • astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
    • Modified history saving logic to account for user-aborted agent runs, ensuring partial outputs are recorded.
    • Updated _save_to_history to accept a user_aborted flag and handle LLMResponse objects more flexibly.
  • astrbot/core/platform/sources/webchat/webchat_event.py
    • Refactored attachment storage from imgs_dir to a more general attachments_dir to improve persistence of various file types.
  • astrbot/core/utils/active_event_registry.py
    • Added request_agent_stop_all method to signal agent runners to stop without interrupting event propagation.
  • astrbot/dashboard/routes/chat.py
    • Implemented a new /chat/stop API endpoint to allow stopping active chat sessions from the frontend.
    • Updated attachment retrieval logic to search for files in both the new attachments_dir and the legacy imgs_dir.
  • dashboard/src/components/chat/Chat.vue
    • Integrated the new ChatInput component's stop event and isRunning prop to enable stopping active chat sessions from the main chat interface.
  • dashboard/src/components/chat/ChatInput.vue
    • Added a conditional stop button that appears when a session is running, allowing users to interrupt ongoing agent generation.
    • Updated component props to include isRunning and emitted a new stop event.
  • dashboard/src/components/chat/StandaloneChat.vue
    • Integrated the new ChatInput component's stop event and isRunning prop for standalone chat sessions.
  • dashboard/src/composables/useMessages.ts
    • Introduced state variables (currentRequestController, currentReader, userStopRequested) and a stopMessage function to manage and abort ongoing chat requests.
    • Modified sendMessage to use an AbortController and handle streaming reader cancellation.
    • Updated error handling to differentiate between user-initiated stops and other errors.
  • dashboard/src/i18n/locales/en-US/features/chat.json
    • Added a new translation key for 'Stop generating'.
  • dashboard/src/i18n/locales/zh-CN/features/chat.json
    • Added a new translation key for '停止生成'.
  • tests/test_tool_loop_agent_runner.py
    • Added a new mock provider (MockAbortableStreamProvider) and a test case to verify that agent runs can be stopped and partial messages are persisted.
Activity
  • A new command /stop has been introduced to allow users to stop active agent sessions.
  • The backend agent runner now includes mechanisms to handle stop requests gracefully, preserving partial outputs.
  • The chat user interface has been updated with a 'Stop generating' button that appears when an agent is running.
  • A new API endpoint /chat/stop was added to facilitate stopping chat sessions from the frontend.
  • The storage location for chat attachments has been standardized to improve persistence.
  • History saving logic was adjusted to correctly record partial outputs even when an agent run is aborted by the user.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@dosubot dosubot bot added the feature:chatui The bug / feature is about astrbot's chatui, webchat label Feb 23, 2026
@Soulter Soulter closed this Feb 23, 2026
@dosubot
Copy link

dosubot bot commented Feb 23, 2026

Related Documentation

1 document(s) may need updating based on files changed in this PR:

AstrBotTeam's Space

pr4697的改动
View Suggested Changes
@@ -151,12 +151,14 @@
 `/stop` 命令是内置对话命令的一部分,与 `/reset`、`/his` 等命令一样,可在任何对话平台(Telegram、QQ、微信、Discord 等)中使用。该命令的详细说明已正式收录于官方用户文档(zh/use/command.md 和 en/use/command.md)。
 
 ##### WebUI 停止按钮
-在 Dashboard 聊天界面,当 Agent 正在执行任务或模型正在生成回复时,输入框右侧的发送按钮会变为停止按钮(红色方块图标):
-
-- 停止按钮在 Agent 运行时自动出现,替代发送按钮
+在 Dashboard 聊天界面,当 Agent 正在执行任务或模型正在生成回复时,输入框右侧的发送按钮会变为停止按钮:
+
+- 停止按钮使用 `mdi-stop` 图标,在 `isRunning` 状态(流式生成或对话运行中)时自动出现,替代发送按钮
+- 鼠标悬停时显示提示文本:"停止生成"(中文)/ "Stop generating"(英文)
 - 点击按钮可中断当前的生成过程
 - 中断后,已生成的部分内容会被保留在对话历史中
-- 停止后会通过 API 调用请求中止当前会话的 Agent 任务
+- 停止后会通过 `/api/chat/stop` API 端点请求中止当前会话的 Agent 任务
+- 前端通过状态管理确保错误处理被抑制,避免用户主动停止时显示误报错误
 
 该功能的详细说明已正式收录于官方用户文档(zh/use/webui.md 和 en/use/webui.md 的"聊天"章节)。
 
@@ -167,27 +169,46 @@
 ##### 内置 Agent Runner(工具循环 Agent Runner)
 对于内置 Agent Runner,停止请求是平滑的(graceful),会保留中断前的部分输出:
 
-- 使用 `request_agent_stop_all()` 方法,不中断事件传播,允许后续流程(如历史记录保存)继续执行
+- 使用 `request_agent_stop_all()` 方法(`active_event_registry`),不中断事件传播,允许后续流程(如历史记录保存)继续执行
+- 该方法设置 `agent_stop_requested` 标志,返回被请求停止的任务数量
 - 系统消息提示:"[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]"
-- Agent 转换为 DONE 状态,并触发 on_agent_done 钩子
-- 对话历史和会话状态得以保留
-- 响应类型标记为 "aborted"
-- Agent 在执行过程中会定期检查 `_stop_requested` 标志,可平滑退出
+- Agent Runner 通过后台监控任务(`_watch_agent_stop_signal`)周期性检查停止信号
+- 检测到停止信号后,调用 `request_stop()` 方法设置内部 `_stop_requested` 标志位
+- Agent 在流式生成或工具调用过程中检查 `_stop_requested` 标志,并在适当时机平滑退出
+- Agent 转换为 DONE 状态,并触发 `on_agent_done` 钩子
+- 对话历史和会话状态得以保留,响应类型标记为 "aborted"
+- `was_aborted()` 方法返回 `true`,表明任务被用户主动中止
 
 ##### 第三方 Agent Runner(如 Dify、Coze)
 对于第三方 Agent Runner,停止请求会完全中断事件传播:
 
-- 使用 `stop_all()` 方法,完全停止事件流
+- 使用 `stop_all()` 方法(`active_event_registry`),完全停止事件流
 - 不保留中间状态,直接终止任务执行
 
 #### 技术实现要点
 
-- `request_agent_stop_all()` 方法(active_event_registry):用于平滑停止,设置停止标志但不中断事件传播
-- `stop_all()` 方法(active_event_registry):用于硬停止,完全中断事件传播
-- `request_stop()` 方法(工具循环 Agent Runner):设置 `_stop_requested` 标志位
-- `was_aborted()` 方法(工具循环 Agent Runner):检查任务是否被用户主动中止
-- Agent 执行过程中通过后台监控任务(`_watch_agent_stop_signal`)检查停止信号
-- Dashboard 通过 `/api/chat/stop` API 端点触发停止请求
+##### 后端实现
+- `request_agent_stop_all()` 方法(`active_event_registry`):用于平滑停止,设置 `agent_stop_requested` 标志但不中断事件传播,返回被请求停止的任务数量
+- `stop_all()` 方法(`active_event_registry`):用于硬停止,完全中断事件传播
+- `request_stop()` 方法(`tool_loop_agent_runner`):设置内部 `_stop_requested` 和 `_aborted` 标志位
+- `was_aborted()` 方法(`tool_loop_agent_runner`):检查任务是否被用户主动中止
+- Agent 执行过程中通过后台监控任务(`_watch_agent_stop_signal`)周期性检查停止信号
+- `/api/chat/stop` POST 端点(`chat.py`):接收前端停止请求,验证会话所有权后调用 `request_agent_stop_all()`
+
+##### 历史记录持久化改进
+- `_save_to_history()` 方法新增 `user_aborted` 参数,支持在用户中止时仍保存对话历史
+- 历史保存条件从 `not event.is_stopped()` 改为 `not event.is_stopped() or agent_runner.was_aborted()`
+- 确保即使会话被中止,已生成的部分内容也能正确保存到历史记录中
+- 空响应在用户中止时会被妥善处理,不会导致历史记录丢失
+
+##### 前端实现
+- 状态管理(`useMessages.ts`):新增 `currentRequestController`、`currentReader`、`currentRunningSessionId` 和 `userStopRequested` 状态
+- `stopMessage()` 函数:调用 `/api/chat/stop` API 端点,取消 SSE reader,中止 fetch 请求
+- 错误处理优化:检查 `userStopRequested` 标志,在用户主动停止时抑制错误提示
+- 停止按钮(`ChatInput.vue`):根据 `isRunning` 状态动态切换发送/停止按钮,正确清理流式状态和活跃 SSE 连接
+
+##### 附件持久化修复
+- `_create_attachment_from_file()` 方法检查多个候选路径(`attachments_dir` 和 `legacy_img_dir`),确保文件段能够正确持久化和检索
 
 ---
 

[Accept] [Decline]

Note: You must be authenticated to accept/decline updates.

How did I do? Any feedback?  Join Discord

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 1 个问题,并给出了一些整体性的反馈:

  • useMessages.stopMessage 中,userStopRequested 在检查有效的 sessionId 之前就被设置为 true,而当 sessionId 为 falsy 时你会提前返回,但没有重置该值;建议将这个赋值移动到 sessionId 检查之后(或在提前返回时重置它),以避免在后续发送中抑制错误日志。
  • ChatRoute._create_attachment_from_file 中,你现在会同时探测 self.attachments_dirself.legacy_img_dir;请确保在所有运行时场景下,该类上的 legacy_img_dir 属性总是已定义,或者在使用前加一层保护(例如通过 getattr),以避免在没有 legacy 目录时运行导致属性错误。
  • run_agent 中与 stop-watcher 相关的取消逻辑在多个分支(正常完成、abort 和异常路径)中被重复实现;建议抽取一个小的帮助函数来安全地取消并等待 stop_watcher,以减少重复代码和清理逻辑不一致的风险。
给 AI Agents 的提示
Please address the comments from this code review:

## Overall Comments
- In `useMessages.stopMessage`, `userStopRequested` is set to `true` before you check for a valid `sessionId`, and you early-return without resetting it when `sessionId` is falsy; consider moving that assignment after the `sessionId` check (or resetting it on early return) to avoid suppressing error logging for subsequent sends.
- In `ChatRoute._create_attachment_from_file`, you now probe both `self.attachments_dir` and `self.legacy_img_dir`; please ensure `legacy_img_dir` is always defined on this class in all runtime contexts, or guard its use (e.g., via `getattr`) to avoid attribute errors when running without legacy directories.
- The stop-watcher cancellation logic in `run_agent` is duplicated in multiple branches (normal completion, aborted, and exception paths); consider extracting a small helper to cancel and await `stop_watcher` safely to reduce repetition and the risk of inconsistent cleanup.

## Individual Comments

### Comment 1
<location> `tests/test_tool_loop_agent_runner.py:447-451` </location>
<code_context>
+    assert any(resp.type == "aborted" for resp in rest_responses)
+    assert runner.was_aborted() is True
+
+    final_resp = runner.get_final_llm_resp()
+    assert final_resp is not None
+    assert final_resp.role == "assistant"
+    assert final_resp.completion_text == "partial "
+    assert runner.run_context.messages[-1].role == "assistant"
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Assert that hooks are invoked correctly on abort to ensure the new on_agent_done behavior is covered.

This test doesn't verify that `agent_hooks.on_agent_done` is invoked on abort. To cover the new behavior, please:

- Assert that `mock_hooks.on_agent_done` (or your stubbed method) is called exactly once.
- Optionally, assert it is called with the `run_context` and `final_resp` from `runner.get_final_llm_resp()`.

That ensures the abort path exercises hooks and guards against regressions where they’re skipped.

Suggested implementation:

```python
    rest_responses = []
    async for response in step_iter:
        rest_responses.append(response)

    assert any(resp.type == "aborted" for resp in rest_responses)
    assert runner.was_aborted() is True

    # Ensure final response and messages are set correctly on abort
    final_resp = runner.get_final_llm_resp()
    assert final_resp is not None
    assert final_resp.role == "assistant"
    assert final_resp.completion_text == "partial "
    assert runner.run_context.messages[-1].role == "assistant"

    # Ensure hooks are invoked correctly on abort
    mock_hooks.on_agent_done.assert_called_once_with(runner.run_context, final_resp)

```

These edits assume:
1. A `mock_hooks` fixture or mock object is already created in this test and passed into the runner so that `on_agent_done` is a `Mock` with `assert_called_once_with` available. If your hooks object is named differently (e.g., `agent_hooks` or `hooks`), update the assertion line accordingly.
2. `on_agent_done`'s signature is `(run_context, final_resp)`. If your implementation uses a different signature (e.g., keyword arguments or additional parameters), adjust the `assert_called_once_with` arguments to match.
</issue_to_address>

Sourcery 对开源项目免费使用——如果你喜欢我们的代码审查,请考虑分享一下 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈来改进后续的审查。
Original comment in English

Hey - I've found 1 issue, and left some high level feedback:

  • In useMessages.stopMessage, userStopRequested is set to true before you check for a valid sessionId, and you early-return without resetting it when sessionId is falsy; consider moving that assignment after the sessionId check (or resetting it on early return) to avoid suppressing error logging for subsequent sends.
  • In ChatRoute._create_attachment_from_file, you now probe both self.attachments_dir and self.legacy_img_dir; please ensure legacy_img_dir is always defined on this class in all runtime contexts, or guard its use (e.g., via getattr) to avoid attribute errors when running without legacy directories.
  • The stop-watcher cancellation logic in run_agent is duplicated in multiple branches (normal completion, aborted, and exception paths); consider extracting a small helper to cancel and await stop_watcher safely to reduce repetition and the risk of inconsistent cleanup.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `useMessages.stopMessage`, `userStopRequested` is set to `true` before you check for a valid `sessionId`, and you early-return without resetting it when `sessionId` is falsy; consider moving that assignment after the `sessionId` check (or resetting it on early return) to avoid suppressing error logging for subsequent sends.
- In `ChatRoute._create_attachment_from_file`, you now probe both `self.attachments_dir` and `self.legacy_img_dir`; please ensure `legacy_img_dir` is always defined on this class in all runtime contexts, or guard its use (e.g., via `getattr`) to avoid attribute errors when running without legacy directories.
- The stop-watcher cancellation logic in `run_agent` is duplicated in multiple branches (normal completion, aborted, and exception paths); consider extracting a small helper to cancel and await `stop_watcher` safely to reduce repetition and the risk of inconsistent cleanup.

## Individual Comments

### Comment 1
<location> `tests/test_tool_loop_agent_runner.py:447-451` </location>
<code_context>
+    assert any(resp.type == "aborted" for resp in rest_responses)
+    assert runner.was_aborted() is True
+
+    final_resp = runner.get_final_llm_resp()
+    assert final_resp is not None
+    assert final_resp.role == "assistant"
+    assert final_resp.completion_text == "partial "
+    assert runner.run_context.messages[-1].role == "assistant"
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Assert that hooks are invoked correctly on abort to ensure the new on_agent_done behavior is covered.

This test doesn't verify that `agent_hooks.on_agent_done` is invoked on abort. To cover the new behavior, please:

- Assert that `mock_hooks.on_agent_done` (or your stubbed method) is called exactly once.
- Optionally, assert it is called with the `run_context` and `final_resp` from `runner.get_final_llm_resp()`.

That ensures the abort path exercises hooks and guards against regressions where they’re skipped.

Suggested implementation:

```python
    rest_responses = []
    async for response in step_iter:
        rest_responses.append(response)

    assert any(resp.type == "aborted" for resp in rest_responses)
    assert runner.was_aborted() is True

    # Ensure final response and messages are set correctly on abort
    final_resp = runner.get_final_llm_resp()
    assert final_resp is not None
    assert final_resp.role == "assistant"
    assert final_resp.completion_text == "partial "
    assert runner.run_context.messages[-1].role == "assistant"

    # Ensure hooks are invoked correctly on abort
    mock_hooks.on_agent_done.assert_called_once_with(runner.run_context, final_resp)

```

These edits assume:
1. A `mock_hooks` fixture or mock object is already created in this test and passed into the runner so that `on_agent_done` is a `Mock` with `assert_called_once_with` available. If your hooks object is named differently (e.g., `agent_hooks` or `hooks`), update the assertion line accordingly.
2. `on_agent_done`'s signature is `(run_context, final_resp)`. If your implementation uses a different signature (e.g., keyword arguments or additional parameters), adjust the `assert_called_once_with` arguments to match.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +447 to +451
final_resp = runner.get_final_llm_resp()
assert final_resp is not None
assert final_resp.role == "assistant"
assert final_resp.completion_text == "partial "
assert runner.run_context.messages[-1].role == "assistant"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): 断言在 abort 时 hooks 会被正确调用,以确保覆盖新的 on_agent_done 行为。

当前这个测试没有验证在 abort 时 agent_hooks.on_agent_done 是否被调用。为了覆盖新的行为,请:

  • 断言 mock_hooks.on_agent_done(或你 stub 的方法)恰好被调用一次。
  • (可选)断言它是使用来自 runner.get_final_llm_resp()run_contextfinal_resp 作为参数被调用的。

这样可以确保 abort 路径会执行 hooks,并防止它们被跳过而造成回归。

建议实现:

    rest_responses = []
    async for response in step_iter:
        rest_responses.append(response)

    assert any(resp.type == "aborted" for resp in rest_responses)
    assert runner.was_aborted() is True

    # Ensure final response and messages are set correctly on abort
    final_resp = runner.get_final_llm_resp()
    assert final_resp is not None
    assert final_resp.role == "assistant"
    assert final_resp.completion_text == "partial "
    assert runner.run_context.messages[-1].role == "assistant"

    # Ensure hooks are invoked correctly on abort
    mock_hooks.on_agent_done.assert_called_once_with(runner.run_context, final_resp)

这些修改基于以下假设:

  1. 测试中已经创建了一个 mock_hooks fixture 或 mock 对象,并将其传入 runner,从而 on_agent_done 是一个带有 assert_called_once_with 方法的 Mock。如果你的 hooks 对象名字不同(例如 agent_hookshooks),请相应地更新断言那一行。
  2. on_agent_done 的函数签名为 (run_context, final_resp)。如果你的实现使用了不同的签名(例如关键字参数或额外的参数),请相应调整 assert_called_once_with 的参数以保持一致。
Original comment in English

suggestion (testing): Assert that hooks are invoked correctly on abort to ensure the new on_agent_done behavior is covered.

This test doesn't verify that agent_hooks.on_agent_done is invoked on abort. To cover the new behavior, please:

  • Assert that mock_hooks.on_agent_done (or your stubbed method) is called exactly once.
  • Optionally, assert it is called with the run_context and final_resp from runner.get_final_llm_resp().

That ensures the abort path exercises hooks and guards against regressions where they’re skipped.

Suggested implementation:

    rest_responses = []
    async for response in step_iter:
        rest_responses.append(response)

    assert any(resp.type == "aborted" for resp in rest_responses)
    assert runner.was_aborted() is True

    # Ensure final response and messages are set correctly on abort
    final_resp = runner.get_final_llm_resp()
    assert final_resp is not None
    assert final_resp.role == "assistant"
    assert final_resp.completion_text == "partial "
    assert runner.run_context.messages[-1].role == "assistant"

    # Ensure hooks are invoked correctly on abort
    mock_hooks.on_agent_done.assert_called_once_with(runner.run_context, final_resp)

These edits assume:

  1. A mock_hooks fixture or mock object is already created in this test and passed into the runner so that on_agent_done is a Mock with assert_called_once_with available. If your hooks object is named differently (e.g., agent_hooks or hooks), update the assertion line accordingly.
  2. on_agent_done's signature is (run_context, final_resp). If your implementation uses a different signature (e.g., keyword arguments or additional parameters), adjust the assert_called_once_with arguments to match.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new feature allowing users to stop ongoing agent conversations. The changes involve adding a /stop command to the bot's built-in commands, which utilizes a new request_agent_stop_all mechanism in the active_event_registry to signal agents to halt execution without fully terminating the event. The ToolLoopAgentRunner was updated to handle these stop requests gracefully, preserving partial outputs and marking the run as aborted. The conversation history saving logic was adjusted to correctly record these aborted sessions. On the frontend, a 'Stop generating' button was added to the chat input, integrating with a new /chat/stop API endpoint to trigger the agent termination. Additionally, the attachment storage directory was generalized from imgs_dir to attachments_dir, and a minor indentation bug in tool call result handling within the dashboard's chat route was fixed. Review comments highlighted areas for improving code clarity, reducing duplication (especially for task cancellation logic), using constants for system messages, and adding type hints for better maintainability.

Comment on lines +477 to +483
accumulated_parts.append(
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
}
)
tool_calls.pop(tc_id, None)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The indentation for the accumulated_parts.append and tool_calls.pop lines within the if tc_id in tool_calls: block is incorrect. These lines should be at the same indentation level as tool_calls[tc_id]["finished_ts"] = tcr.get("ts") to be part of the if block. As it stands, they are outside the if block, which might lead to unexpected behavior if tc_id is not in tool_calls.

Suggested change
accumulated_parts.append(
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
}
)
tool_calls.pop(tc_id, None)
if tc_id in tool_calls:
tool_calls[tc_id]["result"] = tcr.get("result")
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
accumulated_parts.append(
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
}
)
tool_calls.pop(tc_id, None)

Comment on lines +250 to +251
if agent_runner.done() and (
not event.is_stopped() or agent_runner.was_aborted()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The condition not event.is_stopped() or agent_runner.was_aborted() seems a bit redundant. If agent_runner.was_aborted() is true, it implies the agent stopped, so event.is_stopped() might also be true or irrelevant. It would be clearer to state the conditions explicitly, perhaps if agent_runner.done() and (agent_runner.was_aborted() or not event.is_stopped()) to emphasize that saving happens if it's done and either aborted or not stopped by other means.

                    # 保存历史记录
                    if agent_runner.done() and (agent_runner.was_aborted() or not event.is_stopped()):

Comment on lines +352 to +353
if not llm_response and not user_aborted:
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The condition not llm_response and not user_aborted might be simplified. If user_aborted is true, llm_response could still be None if the interruption happened very early. The logic seems to correctly handle this, but the phrasing could be more direct, e.g., if not llm_response and not user_aborted: return implies that if user_aborted is true, we proceed even if llm_response is None, which is then handled by setting llm_response = LLMResponse(...).

Comment on lines +361 to +364
llm_resp = LLMResponse(
role="assistant",
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the previous comment, the completion_text for an interrupted response is hardcoded here. It should be a constant to avoid duplication and improve maintainability.

Suggested change
llm_resp = LLMResponse(
role="assistant",
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
)
llm_resp = llm_resp_result
if llm_resp.role != "assistant":
# Use the same constant for consistency
INTERRUPTED_MESSAGE = "[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]"
llm_resp = LLMResponse(
role="assistant",
completion_text=INTERRUPTED_MESSAGE,
)

Comment on lines +23 to +24
def _should_stop_agent(astr_event) -> bool:
return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The function _should_stop_agent checks for astr_event.is_stopped() and astr_event.get_extra("agent_stop_requested"). While this works, it might be more robust to have a single, clear method on AstrMessageEvent that encapsulates all conditions for stopping, making the intent clearer and centralizing the logic.

Suggested change
def _should_stop_agent(astr_event) -> bool:
return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested"))
def _should_stop_agent(astr_event) -> bool:
# Consider adding a dedicated method to AstrMessageEvent for clearer intent
return astr_event.is_stopped() or astr_event.is_agent_stop_requested()

def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id)
os.makedirs(imgs_dir, exist_ok=True)
os.makedirs(attachments_dir, exist_ok=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The os.makedirs call is made in the __init__ method of WebChatMessageEvent. This means that every time a WebChatMessageEvent is instantiated, it attempts to create this directory. While exist_ok=True prevents errors, it's an unnecessary operation that could be moved to a more appropriate place, such as application startup or a singleton initialization, to be called only once.

Comment on lines +140 to +141
self._stop_requested = False
self._aborted = False
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These new attributes _stop_requested and _aborted are introduced without being explicitly typed. While Python's dynamic typing allows this, adding type hints would improve readability and maintainability, especially for a class with a complex lifecycle like ToolLoopAgentRunner.

Suggested change
self._stop_requested = False
self._aborted = False
self.run_context = run_context
self._stop_requested: bool = False
self._aborted: bool = False

Comment on lines +85 to +88
const currentRequestController = ref<AbortController | null>(null);
const currentReader = ref<ReadableStreamDefaultReader<Uint8Array> | null>(null);
const currentRunningSessionId = ref('');
const userStopRequested = ref(false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These new ref variables (currentRequestController, currentReader, currentRunningSessionId, userStopRequested) are added to manage the state of ongoing requests and user-initiated stops. While functional, it might be beneficial to group related state variables into a single reactive object or a dedicated class for better organization and encapsulation, especially as the component grows.

Comment on lines +622 to +624
} catch (err) {
// ignore reader cancel failures
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The try...catch block around currentReader.value?.cancel() with an empty catch block (// ignore reader cancel failures) can hide important errors. While the intention is to ignore expected CancelledErrors, it might inadvertently suppress other unexpected exceptions during cancellation. It's generally better to log or handle specific exceptions, or at least log the ignored error at a debug level.

        try {
            await currentReader.value?.cancel();
        } catch (err) {
            console.debug('Reader cancellation failed (possibly expected):', err);
        }

Comment on lines +108 to +127
class MockAbortableStreamProvider(MockProvider):
async def text_chat_stream(self, **kwargs):
abort_signal = kwargs.get("abort_signal")
yield LLMResponse(
role="assistant",
completion_text="partial ",
is_chunk=True,
)
if abort_signal and abort_signal.is_set():
yield LLMResponse(
role="assistant",
completion_text="partial ",
is_chunk=False,
)
return
yield LLMResponse(
role="assistant",
completion_text="partial final",
is_chunk=False,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The MockAbortableStreamProvider is a good addition for testing the stop functionality. However, the abort_signal parameter is expected to be a specific type (e.g., asyncio.Event). Adding a type hint for abort_signal in the method signature would improve clarity and help catch potential type-related issues during development.

Suggested change
class MockAbortableStreamProvider(MockProvider):
async def text_chat_stream(self, **kwargs):
abort_signal = kwargs.get("abort_signal")
yield LLMResponse(
role="assistant",
completion_text="partial ",
is_chunk=True,
)
if abort_signal and abort_signal.is_set():
yield LLMResponse(
role="assistant",
completion_text="partial ",
is_chunk=False,
)
return
yield LLMResponse(
role="assistant",
completion_text="partial final",
is_chunk=False,
)
class MockAbortableStreamProvider(MockProvider):
async def text_chat_stream(self, abort_signal: asyncio.Event | None = None, **kwargs):

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:chatui The bug / feature is about astrbot's chatui, webchat size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant