diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index ce2c0c65..e8ba3269 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -1573,20 +1573,7 @@ extension AppControllerDesktopThreadActions on AppController { return; } if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { - try { - await goTaskServiceClientInternal.cancelTask( - route: GoTaskServiceRoute.externalAcpSingle, - target: assistantExecutionTargetForSession(sessionKey), - sessionId: sessionKey, - threadId: sessionKey, - association: taskThreadForSessionInternal( - sessionKey, - )?.openClawTaskAssociation, - ); - } catch (error) { - debugPrint('OpenClaw cancellation fallback: $error'); - // Best effort cancellation only. Local state must still leave pending. - } + await cancelAssistantTaskForSessionInternal(sessionKey); removeQueuedOpenClawGatewayTurnsForSessionInternal(sessionKey); removeActiveOpenClawGatewayTurnsForSessionInternal(sessionKey); markOpenClawGatewayTurnAbortedInternal(sessionKey); @@ -1595,6 +1582,25 @@ extension AppControllerDesktopThreadActions on AppController { } } + Future cancelAssistantTaskForSessionInternal(String sessionKey) async { + final normalized = normalizedAssistantSessionKeyInternal(sessionKey); + final association = taskThreadForSessionInternal( + normalized, + )?.openClawTaskAssociation; + try { + await goTaskServiceClientInternal.cancelTask( + route: GoTaskServiceRoute.externalAcpSingle, + target: assistantExecutionTargetForSession(normalized), + sessionId: normalized, + threadId: normalized, + association: association, + ); + } catch (error) { + debugPrint('OpenClaw cancellation fallback: $error'); + // Best effort cancellation only. Local state must still leave pending. + } + } + Future prepareForExit() async { try { await abortRun(); diff --git a/lib/features/settings/settings_archived_tasks_panel.dart b/lib/features/settings/settings_archived_tasks_panel.dart index 83b73624..b560e90f 100644 --- a/lib/features/settings/settings_archived_tasks_panel.dart +++ b/lib/features/settings/settings_archived_tasks_panel.dart @@ -10,11 +10,13 @@ class SettingsArchivedTasksPanel extends StatefulWidget { required this.sessions, required this.onRestore, required this.onDelete, + required this.onStop, }); final List sessions; final Future Function(String sessionKey) onRestore; final Future Function(String sessionKey) onDelete; + final Future Function(String sessionKey)? onStop; @override State createState() => @@ -152,6 +154,9 @@ class _SettingsArchivedTasksPanelState onSelectionChanged: (selected) => _toggleSessionSelection(session.key, selected), onRestore: () => widget.onRestore(session.key), + onStop: widget.onStop == null + ? null + : () => widget.onStop!(session.key), onDelete: () async { final confirmed = await _confirmDelete(context, session); if (confirmed) { @@ -460,6 +465,7 @@ class _ArchivedTaskTile extends StatelessWidget { required this.selected, required this.onSelectionChanged, required this.onRestore, + required this.onStop, required this.onDelete, }); @@ -467,6 +473,7 @@ class _ArchivedTaskTile extends StatelessWidget { final bool selected; final ValueChanged onSelectionChanged; final Future Function() onRestore; + final Future Function()? onStop; final Future Function() onDelete; @override @@ -551,6 +558,17 @@ class _ArchivedTaskTile extends StatelessWidget { icon: const Icon(Icons.unarchive_outlined), label: Text(appText('解除归档', 'Restore')), ), + if (onStop != null) + FilledButton.tonalIcon( + key: ValueKey( + 'settings-archived-task-stop-${session.key}', + ), + onPressed: () async { + await onStop!(); + }, + icon: const Icon(Icons.stop_rounded), + label: Text(appText('停止', 'Stop')), + ), IconButton( key: ValueKey( 'settings-archived-task-delete-${session.key}', diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index 64bcc759..ee9487ed 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -345,6 +345,10 @@ class _SettingsPageState extends State { await widget.controller.deleteArchivedAssistantTask(sessionKey); } + Future _stopArchivedTask(String sessionKey) async { + await widget.controller.cancelAssistantTaskForSessionInternal(sessionKey); + } + Future _loadAboutSnapshot() async { final bridgeEndpoint = widget.controller.resolveGatewayAcpEndpointInternal() ?? @@ -523,6 +527,7 @@ class _SettingsPageState extends State { sessions: controller.archivedAssistantSessions, onRestore: _restoreArchivedTask, onDelete: _deleteArchivedTask, + onStop: (sessionKey) => _stopArchivedTask(sessionKey), ), ), ] else if (currentTab == SettingsTab.remoteDesktop) ...[ diff --git a/test/features/settings/settings_archived_tasks_panel_test.dart b/test/features/settings/settings_archived_tasks_panel_test.dart index 50e9f8a2..94abd320 100644 --- a/test/features/settings/settings_archived_tasks_panel_test.dart +++ b/test/features/settings/settings_archived_tasks_panel_test.dart @@ -18,6 +18,7 @@ void main() { sessions: const [], onRestore: (_) async {}, onDelete: (_) async {}, + onStop: null, ), ), ); @@ -66,6 +67,9 @@ void main() { onDelete: (sessionKey) async { calls.add('delete:$sessionKey'); }, + onStop: (sessionKey) async { + calls.add('stop:$sessionKey'); + }, ), ), ); @@ -82,6 +86,14 @@ void main() { expect(calls, contains('restore:draft:archived-task')); + await tester.tap( + find.byKey( + const ValueKey('settings-archived-task-stop-draft:archived-task'), + ), + ); + await tester.pump(); + expect(calls, contains('stop:draft:archived-task')); + await tester.tap( find.byKey( const ValueKey('settings-archived-task-delete-draft:archived-task'), @@ -132,6 +144,9 @@ void main() { onDelete: (sessionKey) async { calls.add('delete:$sessionKey'); }, + onStop: (sessionKey) async { + calls.add('stop:$sessionKey'); + }, ), ), ); @@ -174,6 +189,9 @@ void main() { onDelete: (sessionKey) async { calls.add('delete:$sessionKey'); }, + onStop: (sessionKey) async { + calls.add('stop:$sessionKey'); + }, ), ), ); diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 48a7414d..0a23a48d 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -3627,6 +3627,46 @@ void main() { }, ); + test( + 'cancelAssistantTaskForSessionInternal stops an archived OpenClaw task by session key', + () async { + final fakeGoTaskService = _BlockingGoTaskServiceClient(); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(() { + fakeGoTaskService.completeAll(); + controller.dispose(); + }); + + const sessionKey = 'archived-running-openclaw'; + await _selectGatewaySession(controller, sessionKey); + final pendingFuture = controller.sendChatMessage('stop me'); + await _waitForThreadLifecycleStatus(controller, sessionKey, 'running'); + await controller.saveAssistantTaskArchived(sessionKey, true); + + await controller.cancelAssistantTaskForSessionInternal(sessionKey); + + expect(fakeGoTaskService.cancelledSessionIds, [sessionKey]); + expect( + controller.archivedAssistantSessions.map((item) => item.key), + [sessionKey], + ); + + fakeGoTaskService.complete( + sessionKey, + const GoTaskServiceResult( + success: true, + message: 'stopped', + turnId: 'turn-archived-running-openclaw', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await pendingFuture; + }, + ); + test( 'continueAssistantTaskInternal requeues a stopped OpenClaw task without clearing queued work', () async {