From e156ecf80823d179ecbc4f07abd9f28bbca6ff40 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 8 May 2026 14:19:26 +0800 Subject: [PATCH] fix: preserve acp interrupted task results --- ...pp_controller_desktop_runtime_helpers.dart | 43 +++++-- ...app_controller_desktop_thread_actions.dart | 17 ++- .../assistant_page_state_closure.dart | 13 ++ lib/widgets/assistant_task_progress_bar.dart | 118 ++++++++++++++++++ .../assistant_task_progress_bar_test.dart | 91 ++++++++++++++ .../assistant_execution_target_test.dart | 74 ++++++++++- 6 files changed, 339 insertions(+), 17 deletions(-) create mode 100644 lib/widgets/assistant_task_progress_bar.dart create mode 100644 test/features/assistant/assistant_task_progress_bar_test.dart diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index fc6a24da..7c8d5407 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -228,17 +228,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController { }) { final raw = error.toString().trim(); final lowered = raw.toLowerCase(); - final detailCode = error is GatewayAcpException - ? error.detailCode?.trim().toUpperCase() - : null; - final primaryCode = error is GatewayAcpException - ? error.code?.trim().toUpperCase() - : null; - final acpHttpConnectionClosed = - primaryCode == 'ACP_HTTP_CONNECTION_CLOSED' || - detailCode == 'ACP_HTTP_CONNECTION_CLOSED' || - raw.contains('ACP_HTTP_CONNECTION_CLOSED'); - if (acpHttpConnectionClosed) { + final detailCode = gatewayExecutionDetailCodeInternal(error); + final primaryCode = gatewayExecutionPrimaryCodeInternal(error); + if (isAcpHttpConnectionClosedErrorInternal(error)) { return appText( 'Bridge 响应读取中断;当前对话已保留,下一次发送会继续同一会话。错误码:ACP_HTTP_CONNECTION_CLOSED', 'Bridge response was interrupted; this conversation was kept, and the next send will continue the same session. Error code: ACP_HTTP_CONNECTION_CLOSED', @@ -290,6 +282,27 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return raw; } + String? gatewayExecutionPrimaryCodeInternal(Object error) { + return error is GatewayAcpException + ? error.code?.trim().toUpperCase() + : null; + } + + String? gatewayExecutionDetailCodeInternal(Object error) { + return error is GatewayAcpException + ? error.detailCode?.trim().toUpperCase() + : null; + } + + bool isAcpHttpConnectionClosedErrorInternal(Object error) { + final raw = error.toString().trim(); + final primaryCode = gatewayExecutionPrimaryCodeInternal(error); + final detailCode = gatewayExecutionDetailCodeInternal(error); + return primaryCode == 'ACP_HTTP_CONNECTION_CLOSED' || + detailCode == 'ACP_HTTP_CONNECTION_CLOSED' || + raw.contains('ACP_HTTP_CONNECTION_CLOSED'); + } + String formatAiGatewayHttpErrorInternal(int statusCode, String detail) { final base = switch (statusCode) { 400 => appText( @@ -623,6 +636,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController { final existingThread = requireTaskThreadForSessionInternal( normalizedSessionKey, ); + upsertTaskThreadInternal( + normalizedSessionKey, + lastArtifactSyncAtMs: syncedAtMs, + lastArtifactSyncStatus: 'syncing', + updatedAtMs: syncedAtMs, + ); + recomputeTasksInternal(); + notifyIfActiveInternal(); if (existingThread.workspaceBinding.workspaceKind != WorkspaceKind.localFs) { upsertTaskThreadInternal( diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index f2a53e0a..a25cd6f1 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -352,6 +352,13 @@ extension AppControllerDesktopThreadActions on AppController { persistInThreadContext: true, ); aiGatewayPendingSessionKeysInternal.add(sessionKey); + upsertTaskThreadInternal( + sessionKey, + lifecycleStatus: 'running', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: 'running', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); recomputeTasksInternal(); notifyIfActiveInternal(); try { @@ -452,11 +459,17 @@ extension AppControllerDesktopThreadActions on AppController { ); } catch (error) { clearAiGatewayStreamingTextInternal(sessionKey); + final connectionClosed = isAcpHttpConnectionClosedErrorInternal( + error, + ); upsertTaskThreadInternal( sessionKey, - lifecycleStatus: 'ready', + lifecycleStatus: connectionClosed ? 'interrupted' : 'ready', lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: 'error', + lastResultCode: connectionClosed + ? 'ACP_HTTP_CONNECTION_CLOSED' + : 'error', + lastArtifactSyncStatus: connectionClosed ? 'interrupted' : null, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); appendLocalSessionMessageInternal( diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart index 08e42cca..7764b929 100644 --- a/lib/features/assistant/assistant_page_state_closure.dart +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -22,6 +22,7 @@ import '../../theme/app_palette.dart'; import '../../theme/app_theme.dart'; import '../../widgets/assistant_focus_panel.dart'; import '../../widgets/assistant_artifact_sidebar.dart'; +import '../../widgets/assistant_task_progress_bar.dart'; import '../../widgets/desktop_workspace_scaffold.dart'; import '../../widgets/pane_resize_handle.dart'; import '../../widgets/surface_card.dart'; @@ -111,6 +112,17 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { (defaultComposerHeight + workspaceLowerPaneHeightAdjustmentInternal) .clamp(composerHeightLowerBound, composerHeightUpperBound) .toDouble(); + final thread = controller.taskThreadForSessionInternal( + controller.currentSessionKey, + ); + final progressState = assistantTaskProgressState( + pending: controller.assistantSessionHasPendingRun( + controller.currentSessionKey, + ), + lifecycleStatus: thread?.lifecycleState.status ?? '', + lastResultCode: thread?.lifecycleState.lastResultCode ?? '', + artifactSyncStatus: thread?.lastArtifactSyncStatus ?? '', + ); return SurfaceCard( borderRadius: 0, @@ -149,6 +161,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { ), ), ), + AssistantTaskProgressBar(state: progressState), ColoredBox( color: palette.canvas, child: SizedBox( diff --git a/lib/widgets/assistant_task_progress_bar.dart b/lib/widgets/assistant_task_progress_bar.dart new file mode 100644 index 00000000..5961a0bb --- /dev/null +++ b/lib/widgets/assistant_task_progress_bar.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; + +import '../i18n/app_language.dart'; + +enum AssistantTaskProgressPhase { idle, running, syncingArtifacts, interrupted } + +class AssistantTaskProgressState { + const AssistantTaskProgressState({ + required this.phase, + required this.label, + this.value, + }); + + const AssistantTaskProgressState.idle() + : phase = AssistantTaskProgressPhase.idle, + label = '', + value = null; + + final AssistantTaskProgressPhase phase; + final String label; + final double? value; + + bool get visible => phase != AssistantTaskProgressPhase.idle; + bool get interrupted => phase == AssistantTaskProgressPhase.interrupted; +} + +class AssistantTaskProgressBar extends StatelessWidget { + const AssistantTaskProgressBar({super.key, required this.state}); + + final AssistantTaskProgressState state; + + @override + Widget build(BuildContext context) { + if (!state.visible) { + return const SizedBox.shrink(); + } + final theme = Theme.of(context); + final color = state.interrupted + ? theme.colorScheme.error + : theme.colorScheme.primary; + return Container( + key: const Key('assistant-task-progress-bar'), + constraints: const BoxConstraints(minHeight: 34), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + decoration: BoxDecoration( + color: state.interrupted + ? theme.colorScheme.errorContainer.withValues(alpha: 0.18) + : theme.colorScheme.primaryContainer.withValues(alpha: 0.18), + border: Border( + top: BorderSide(color: theme.dividerColor.withValues(alpha: 0.42)), + bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.42)), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + state.label, + key: const Key('assistant-task-progress-label'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 5), + LinearProgressIndicator( + key: const Key('assistant-task-progress-indicator'), + value: state.value, + minHeight: 3, + color: color, + backgroundColor: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(999), + ), + ], + ), + ); + } +} + +AssistantTaskProgressState assistantTaskProgressState({ + required bool pending, + required String lifecycleStatus, + required String lastResultCode, + required String artifactSyncStatus, +}) { + final syncStatus = artifactSyncStatus.trim().toLowerCase(); + if (pending && syncStatus == 'syncing') { + return AssistantTaskProgressState( + phase: AssistantTaskProgressPhase.syncingArtifacts, + label: appText('正在同步生成文件...', 'Syncing generated files...'), + value: 0.82, + ); + } + if (pending) { + return AssistantTaskProgressState( + phase: AssistantTaskProgressPhase.running, + label: appText('任务运行中...', 'Task running...'), + ); + } + final status = lifecycleStatus.trim().toLowerCase(); + final result = lastResultCode.trim().toUpperCase(); + if (status == 'interrupted' || + syncStatus == 'interrupted' || + result == 'ACP_HTTP_CONNECTION_CLOSED') { + return AssistantTaskProgressState( + phase: AssistantTaskProgressPhase.interrupted, + label: appText( + 'Bridge 响应中断,等待下一次发送续写同一会话。', + 'Bridge response interrupted; the next send will continue this session.', + ), + value: 0.48, + ); + } + return const AssistantTaskProgressState.idle(); +} diff --git a/test/features/assistant/assistant_task_progress_bar_test.dart b/test/features/assistant/assistant_task_progress_bar_test.dart new file mode 100644 index 00000000..ff64c04a --- /dev/null +++ b/test/features/assistant/assistant_task_progress_bar_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/theme/app_theme.dart'; +import 'package:xworkmate/widgets/assistant_task_progress_bar.dart'; + +void main() { + testWidgets('shows running progress while a task is pending', (tester) async { + await tester.pumpWidget( + _buildTestApp( + assistantTaskProgressState( + pending: true, + lifecycleStatus: 'running', + lastResultCode: 'running', + artifactSyncStatus: '', + ), + ), + ); + + expect( + find.byKey(const Key('assistant-task-progress-bar')), + findsOneWidget, + ); + expect(find.text('任务运行中...'), findsOneWidget); + final indicator = tester.widget( + find.byKey(const Key('assistant-task-progress-indicator')), + ); + expect(indicator.value, isNull); + }); + + testWidgets('shows artifact sync progress while files are syncing', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestApp( + assistantTaskProgressState( + pending: true, + lifecycleStatus: 'running', + lastResultCode: 'running', + artifactSyncStatus: 'syncing', + ), + ), + ); + + expect(find.text('正在同步生成文件...'), findsOneWidget); + final indicator = tester.widget( + find.byKey(const Key('assistant-task-progress-indicator')), + ); + expect(indicator.value, 0.82); + }); + + testWidgets('shows interrupted state after ACP connection closes', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestApp( + assistantTaskProgressState( + pending: false, + lifecycleStatus: 'interrupted', + lastResultCode: 'ACP_HTTP_CONNECTION_CLOSED', + artifactSyncStatus: 'interrupted', + ), + ), + ); + + expect(find.text('Bridge 响应中断,等待下一次发送续写同一会话。'), findsOneWidget); + final indicator = tester.widget( + find.byKey(const Key('assistant-task-progress-indicator')), + ); + expect(indicator.value, 0.48); + }); + + testWidgets('hides idle progress state', (tester) async { + await tester.pumpWidget( + _buildTestApp(const AssistantTaskProgressState.idle()), + ); + + expect(find.byKey(const Key('assistant-task-progress-bar')), findsNothing); + }); +} + +Widget _buildTestApp(AssistantTaskProgressState state) { + return MaterialApp( + theme: AppTheme.light(), + home: Material( + child: SizedBox( + width: 420, + child: AssistantTaskProgressBar(state: state), + ), + ), + ); +} diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index e3b37224..fc965683 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -635,6 +635,14 @@ void main() { test( 'sendChatMessage continues the same session after ACP HTTP connection close', () async { + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-acp-interrupt-artifacts-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); final fakeGoTaskService = _RecordingGoTaskServiceClient() ..updatesBeforeNextOutcome.add( const GoTaskServiceUpdate( @@ -657,11 +665,11 @@ void main() { ), ) ..outcomes.add( - const GoTaskServiceResult( + GoTaskServiceResult( success: true, - message: 'continued response', + message: '全部 6 个文件已生成 ✅', turnId: 'turn-2', - raw: {}, + raw: {'artifacts': _generatedArtifactPayloads()}, errorMessage: '', resolvedModel: '', route: GoTaskServiceRoute.externalAcpSingle, @@ -669,6 +677,7 @@ void main() { ); final controller = _connectedController(fakeGoTaskService); addTearDown(controller.dispose); + controller.resolvedUserHomeDirectoryInternal = localWorkspace.path; await controller.sessionsController.switchSession('session-1'); @@ -676,6 +685,13 @@ void main() { expect(fakeGoTaskService.requests, hasLength(1)); expect(fakeGoTaskService.requests.single.resumeSession, isFalse); + expect( + controller + .taskThreadForSessionInternal('session-1') + ?.lifecycleState + .status, + 'interrupted', + ); expect( controller.chatMessages.last.text, 'Bridge 响应读取中断;当前对话已保留,下一次发送会继续同一会话。错误码:ACP_HTTP_CONNECTION_CLOSED', @@ -689,7 +705,22 @@ void main() { expect(fakeGoTaskService.requests, hasLength(2)); expect(fakeGoTaskService.requests.last.resumeSession, isTrue); - expect(controller.chatMessages.last.text, 'continued response'); + expect(controller.chatMessages.last.text, '全部 6 个文件已生成 ✅'); + final thread = controller.taskThreadForSessionInternal('session-1'); + expect(thread?.lifecycleState.status, 'ready'); + expect(thread?.lastArtifactSyncStatus, 'synced'); + expect(thread?.lastArtifactSyncAtMs, greaterThan(0)); + final workspacePath = controller.assistantWorkspacePathForSession( + 'session-1', + ); + for (final artifact in _generatedArtifactPayloads()) { + final relativePath = artifact['relativePath']! as String; + final content = artifact['content']! as String; + expect( + await File('$workspacePath/$relativePath').readAsString(), + content, + ); + } }, ); @@ -847,6 +878,41 @@ class _CapabilityServerCapture { Future close() => _server.close(force: true); } +List> _generatedArtifactPayloads() { + return >[ + { + 'relativePath': '网络与协议专题-图片生成提示词.md', + 'content': 'prompt content', + 'contentType': 'text/markdown', + }, + { + 'relativePath': '小红书风格文案.md', + 'content': 'xiaohongshu copy', + 'contentType': 'text/markdown', + }, + { + 'relativePath': 'X文案.md', + 'content': 'x copy', + 'contentType': 'text/markdown', + }, + { + 'relativePath': '领英文案.md', + 'content': 'linkedin copy', + 'contentType': 'text/markdown', + }, + { + 'relativePath': '云原生网络与协议专题.pptx', + 'content': 'pptx bytes', + 'contentType': 'application/octet-stream', + }, + { + 'relativePath': 'PptxGenJS_脚本.js', + 'content': 'console.log("pptx");', + 'contentType': 'text/javascript', + }, + ]; +} + AppController _connectedController(GoTaskServiceClient client) { return AppController( goTaskServiceClient: client,