From 03cbbc384d9945c9eba5d92c33be2791ae930ec3 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Fri, 11 Sep 2026 21:29:49 +0800 Subject: [PATCH] fix(btw): record an aborted work run as cancelled run_agent reports an abort by setting agent_user_aborted and clearing agent_stop_requested, because an aborted run must not look like a stop request that is still pending. WorkLoop only read the cleared flag, so a work task the user stopped through /task stop ended as completed and /work status reported an aborted task as finished. Treat either marker as a cancellation. The regression test drives the real run_agent with only the model runner replaced, so it exercises the same control flow that produces the markers. Fixes #153 AI-Generated: true Generated-At: 2026-09-11T13:29:48Z --- astrbot/core/agent/btw/work_loop.py | 7 ++++- tests/unit/test_btw_work_loop.py | 48 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/astrbot/core/agent/btw/work_loop.py b/astrbot/core/agent/btw/work_loop.py index 27e58eb55b..dd9f916fc0 100644 --- a/astrbot/core/agent/btw/work_loop.py +++ b/astrbot/core/agent/btw/work_loop.py @@ -254,7 +254,12 @@ async def _execute( raise else: failed = bool(event.get_extra("btw_work_failed")) - cancelled = bool(event.get_extra("agent_stop_requested")) + # An admitted stop request and a run that reported its own abort are + # both cancellations. ``run_agent`` clears ``agent_stop_requested`` + # when it reports the abort, so the stop flag alone misses that case. + cancelled = bool(event.get_extra("agent_stop_requested")) or bool( + event.get_extra("agent_user_aborted") + ) await self.sessions.update_status( session_id, WorkSessionStatus.FAILED diff --git a/tests/unit/test_btw_work_loop.py b/tests/unit/test_btw_work_loop.py index e667a7d366..4782793ab4 100644 --- a/tests/unit/test_btw_work_loop.py +++ b/tests/unit/test_btw_work_loop.py @@ -1,5 +1,6 @@ import asyncio from datetime import UTC, datetime, timedelta +from types import SimpleNamespace from unittest.mock import AsyncMock import pytest @@ -7,6 +8,9 @@ from astrbot.core.agent.btw.types import WorkSessionStatus, is_work_loop_enabled from astrbot.core.agent.btw.work_loop import WorkLoop from astrbot.core.agent.btw.work_sessions import WorkSessionManager +from astrbot.core.astr_agent_run_util import run_agent +from tests.unit.test_astr_agent_run_util import FakeEvent as RunnerEvent +from tests.unit.test_astr_agent_run_util import FakeRunner class FakeEvent: @@ -52,6 +56,31 @@ async def process(self, event): yield "done" +class WorkEvent(RunnerEvent): + """The event surface a real agent run needs inside a work loop.""" + + def __init__(self) -> None: + super().__init__() + self.unified_msg_origin = "umo-1" + self.message_str = "执行命令" + + +class RunAgentExecutor: + """The real ``run_agent`` used as the work loop's executor. + + Only the model runner behind it is a test double, so the terminal status + is decided by the same control flow production uses. + """ + + def __init__(self, runner) -> None: + self.runner = runner + + async def process(self, event): + del event + async for _ in run_agent(self.runner): + yield + + @pytest.mark.asyncio async def test_work_loop_marks_failures_without_exposing_executor_error(): sessions = WorkSessionManager() @@ -235,3 +264,22 @@ async def test_schedule_refuses_after_the_loop_closed(): with pytest.raises(RuntimeError): await work_loop.schedule(FakeEvent("the complete task")) + + +@pytest.mark.asyncio +async def test_work_loop_records_user_abort_as_cancelled(): + """Aborting a real run_agent through the executor must not read as success.""" + event = WorkEvent() + runner = FakeRunner([SimpleNamespace(type="aborted", data={})], event=event) + sessions = WorkSessionManager() + work_loop = WorkLoop(RunAgentExecutor(runner), sessions) + + _ = [item async for item in work_loop.process(event)] + + # The real run_agent clears the stop flag when it reports the abort, so the + # loop cannot rely on that flag to tell a cancellation from a completion. + assert event.get_extra("agent_user_aborted") is True + assert event.get_extra("agent_stop_requested") is False + session = await sessions.get_for_origin(event.unified_msg_origin) + assert session is not None + assert session.status is WorkSessionStatus.CANCELLED