From 41ef452929fd5e169f1c83f47d4da613a6d9d59e Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 8 May 2026 18:14:36 +0800 Subject: [PATCH] fix: hide OpenClaw artifact guard diagnostics --- ...pp_controller_desktop_runtime_helpers.dart | 33 ++++- ...app_controller_desktop_thread_actions.dart | 4 + ...pp_controller_desktop_thread_sessions.dart | 8 ++ .../assistant_page_state_closure.dart | 4 + lib/widgets/assistant_artifact_sidebar.dart | 12 +- .../assistant_artifact_sidebar_test.dart | 50 +++++++ ...troller_thread_workspace_binding_test.dart | 100 +++++++++++++ .../assistant_execution_target_test.dart | 134 +++++++++++++----- 8 files changed, 309 insertions(+), 36 deletions(-) diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 411fd1f2..8372e69d 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -309,6 +309,34 @@ extension AppControllerDesktopRuntimeHelpers on AppController { 'ACP_HTTP_CONNECTION_CLOSED'; } + bool isOpenClawNoExportedArtifactsGuardResultInternal( + GoTaskServiceResult result, + ) { + if (!result.success || result.artifacts.isNotEmpty) { + return false; + } + final rawText = jsonLikeTextForDiagnosticsInternal( + result.raw, + ).toLowerCase(); + final messageText = '${result.message}\n${result.errorMessage}\n$rawText' + .toLowerCase(); + return messageText.contains('未检测到 openclaw 本轮导出的实际文件') || + messageText.contains('未检测到openclaw本轮导出的实际文件') || + messageText.contains('口头下载声明') || + messageText.contains('no_exported_artifacts') || + messageText.contains('no-exported-artifacts') || + messageText.contains('openclaw_artifact_guard') || + messageText.contains('openclaw_no_exported_artifacts'); + } + + String jsonLikeTextForDiagnosticsInternal(Object? value) { + try { + return jsonEncode(value); + } catch (_) { + return value.toString(); + } + } + String? recoverableAcpHttpTransportCodeInternal(Object error) { final raw = error.toString().trim(); final primaryCode = gatewayExecutionPrimaryCodeInternal(error); @@ -682,7 +710,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { upsertTaskThreadInternal( normalizedSessionKey, lastArtifactSyncAtMs: syncedAtMs, - lastArtifactSyncStatus: 'no-artifacts', + lastArtifactSyncStatus: + isOpenClawNoExportedArtifactsGuardResultInternal(result) + ? 'no-exported-artifacts' + : 'no-artifacts', updatedAtMs: syncedAtMs, ); return; diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 692dfe6f..42315cb1 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -442,6 +442,10 @@ extension AppControllerDesktopThreadActions on AppController { ); return; } + if (isOpenClawNoExportedArtifactsGuardResultInternal(result)) { + await persistGoTaskArtifactsForSessionInternal(sessionKey, result); + return; + } final assistantText = result.message.trim(); if (assistantText.isEmpty) { appendLocalSessionMessageInternal( diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 8cc8e7de..1b13cd0a 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -313,6 +313,14 @@ extension AppControllerDesktopThreadSessions on AppController { )?.lastArtifactSyncAtMs; } + String assistantArtifactSyncStatusForSession(String sessionKey) { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + final thread = taskThreadForSessionInternal(normalizedSessionKey); + return thread?.lastArtifactSyncStatus?.trim() ?? ''; + } + Future loadAssistantArtifactSnapshot({ String? sessionKey, }) { diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart index 3931b47c..2fec8ef2 100644 --- a/lib/features/assistant/assistant_page_state_closure.dart +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -342,6 +342,10 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { .assistantArtifactSyncAtMsForSession( controller.currentSessionKey, ), + artifactSyncStatus: controller + .assistantArtifactSyncStatusForSession( + controller.currentSessionKey, + ), onCollapse: () { setState(() { artifactPaneCollapsedInternal = true; diff --git a/lib/widgets/assistant_artifact_sidebar.dart b/lib/widgets/assistant_artifact_sidebar.dart index 6c7005a2..d31429bc 100644 --- a/lib/widgets/assistant_artifact_sidebar.dart +++ b/lib/widgets/assistant_artifact_sidebar.dart @@ -32,6 +32,7 @@ class AssistantArtifactSidebar extends StatefulWidget { required this.workspacePath, required this.workspaceKind, required this.artifactSyncAtMs, + required this.artifactSyncStatus, required this.onCollapse, required this.loadSnapshot, required this.loadPreview, @@ -44,6 +45,7 @@ class AssistantArtifactSidebar extends StatefulWidget { final String workspacePath; final WorkspaceRefKind workspaceKind; final double? artifactSyncAtMs; + final String artifactSyncStatus; final VoidCallback onCollapse; final AssistantArtifactSnapshotLoader loadSnapshot; final AssistantArtifactPreviewLoader loadPreview; @@ -76,7 +78,8 @@ class _AssistantArtifactSidebarState extends State { if (oldWidget.sessionKey != widget.sessionKey || oldWidget.workspacePath != widget.workspacePath || oldWidget.workspaceKind != widget.workspaceKind || - oldWidget.artifactSyncAtMs != widget.artifactSyncAtMs) { + oldWidget.artifactSyncAtMs != widget.artifactSyncAtMs || + oldWidget.artifactSyncStatus != widget.artifactSyncStatus) { _activeTab = AssistantArtifactSidebarTab.files; _selectedEntry = null; _preview = const AssistantArtifactPreview.empty(); @@ -396,6 +399,13 @@ class _AssistantArtifactSidebarState extends State { } String _filesEmptyMessage(AssistantArtifactSnapshot snapshot) { + if (widget.artifactSyncStatus.trim().toLowerCase() == + 'no-exported-artifacts') { + return appText( + '本轮没有检测到实际生成的文件。请重新执行,并要求 OpenClaw 在当前 workspace 中创建文件。', + 'No exported files were detected for this run. Run it again and ask OpenClaw to create files in the current workspace.', + ); + } final filesMessage = snapshot.filesMessage.trim(); if (filesMessage.isNotEmpty) { return filesMessage; diff --git a/test/features/assistant/assistant_artifact_sidebar_test.dart b/test/features/assistant/assistant_artifact_sidebar_test.dart index 07844af8..be06a171 100644 --- a/test/features/assistant/assistant_artifact_sidebar_test.dart +++ b/test/features/assistant/assistant_artifact_sidebar_test.dart @@ -46,6 +46,54 @@ void main() { expect(find.text('artifact-2.txt'), findsAtLeastNWidgets(1)); }); + testWidgets('explains OpenClaw runs with no exported artifacts', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestApp( + artifactSyncAtMs: 1, + artifactSyncStatus: 'no-exported-artifacts', + loadSnapshot: () async => const AssistantArtifactSnapshot( + workspacePath: '/tmp/thread', + workspaceKind: WorkspaceRefKind.localPath, + filesMessage: 'No files found in the recorded working directory.', + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text('本轮没有检测到实际生成的文件。请重新执行,并要求 OpenClaw 在当前 workspace 中创建文件。'), + findsOneWidget, + ); + expect(find.textContaining('口头下载声明'), findsNothing); + expect(find.textContaining('已阻止'), findsNothing); + expect(find.textContaining('artifacts 面板'), findsNothing); + }); + + testWidgets('keeps the ordinary empty directory message', (tester) async { + await tester.pumpWidget( + _buildTestApp( + artifactSyncAtMs: 1, + loadSnapshot: () async => const AssistantArtifactSnapshot( + workspacePath: '/tmp/thread', + workspaceKind: WorkspaceRefKind.localPath, + filesMessage: 'No files found in the recorded working directory.', + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text('No files found in the recorded working directory.'), + findsOneWidget, + ); + expect( + find.text('本轮没有检测到实际生成的文件。请重新执行,并要求 OpenClaw 在当前 workspace 中创建文件。'), + findsNothing, + ); + }); + testWidgets('keeps binary artifacts out of preview flow', (tester) async { var previewLoadCount = 0; @@ -155,6 +203,7 @@ void main() { Widget _buildTestApp({ required double artifactSyncAtMs, + String artifactSyncStatus = '', required Future Function() loadSnapshot, Future Function(AssistantArtifactEntry entry)? loadPreview, @@ -172,6 +221,7 @@ Widget _buildTestApp({ workspacePath: '/tmp/thread', workspaceKind: WorkspaceRefKind.localPath, artifactSyncAtMs: artifactSyncAtMs, + artifactSyncStatus: artifactSyncStatus, onCollapse: () {}, loadSnapshot: loadSnapshot, loadPreview: diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index ec8d5277..392f5572 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -829,6 +829,106 @@ void main() { ); }); + test( + 'records OpenClaw guard status without creating pseudo artifact files', + () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-openclaw-guard-workspace-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + controller.upsertTaskThreadInternal( + 'session-1', + workspaceBinding: WorkspaceBinding( + workspaceId: 'session-1', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + ); + + const result = GoTaskServiceResult( + success: true, + message: + '未检测到 OpenClaw 本轮导出的实际文件。已阻止口头下载声明进入 artifacts 面板;请重新执行并要求 OpenClaw 在 workspace 中真实生成文件。', + turnId: 'turn-1', + raw: {'code': 'OPENCLAW_NO_EXPORTED_ARTIFACTS'}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + await controller.persistGoTaskArtifactsForSessionInternal( + 'session-1', + result, + ); + + expect(await localWorkspace.list(recursive: true).toList(), isEmpty); + final thread = controller.requireTaskThreadForSessionInternal( + 'session-1', + ); + expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts'); + expect(thread.lastArtifactSyncAtMs, greaterThan(0)); + }, + ); + + test('records ordinary empty artifact results as no artifacts', () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-empty-artifacts-workspace-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + controller.upsertTaskThreadInternal( + 'session-1', + workspaceBinding: WorkspaceBinding( + workspaceId: 'session-1', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + ); + + const result = GoTaskServiceResult( + success: true, + message: 'no files this time', + turnId: 'turn-1', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + await controller.persistGoTaskArtifactsForSessionInternal( + 'session-1', + result, + ); + + expect( + controller + .requireTaskThreadForSessionInternal('session-1') + .lastArtifactSyncStatus, + 'no-artifacts', + ); + }); + test('skips download URL artifacts outside the bridge host', () async { final controller = AppController( environmentOverride: const { diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index e92f3319..9380532d 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -726,6 +726,81 @@ void main() { }, ); + test( + 'sendChatMessage hides OpenClaw artifact guard text after an interrupted continuation', + () async { + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-acp-interrupt-guard-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + const guardMessage = + '未检测到 OpenClaw 本轮导出的实际文件。已阻止口头下载声明进入 artifacts 面板;请重新执行并要求 OpenClaw 在 workspace 中真实生成文件。'; + final fakeGoTaskService = _RecordingGoTaskServiceClient() + ..updatesBeforeNextOutcome.add( + const GoTaskServiceUpdate( + sessionId: 'session-1', + threadId: 'session-1', + turnId: 'turn-1', + type: 'delta', + text: 'guard partial output must not persist', + message: '', + pending: true, + error: false, + route: GoTaskServiceRoute.externalAcpSingle, + payload: {}, + ), + ) + ..outcomes.add( + const GatewayAcpException( + 'ACP HTTP connection closed before the response finished arriving', + code: 'ACP_HTTP_CONNECTION_CLOSED', + ), + ) + ..outcomes.add( + const GoTaskServiceResult( + success: true, + message: guardMessage, + turnId: 'turn-2', + raw: {'code': 'OPENCLAW_NO_EXPORTED_ARTIFACTS'}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + final controller = _connectedController(fakeGoTaskService); + addTearDown(controller.dispose); + controller.resolvedUserHomeDirectoryInternal = localWorkspace.path; + + await controller.sessionsController.switchSession('session-1'); + + await controller.sendChatMessage('first turn'); + await controller.sendChatMessage('follow up'); + + expect(fakeGoTaskService.requests, hasLength(2)); + expect(fakeGoTaskService.requests.first.resumeSession, isFalse); + expect(fakeGoTaskService.requests.last.resumeSession, isTrue); + + final transcript = controller.chatMessages + .map((message) => message.text) + .join('\n'); + expect(transcript, isNot(contains('未检测到 OpenClaw 本轮导出的实际文件'))); + expect(transcript, isNot(contains('口头下载声明'))); + expect( + transcript, + isNot(contains('guard partial output must not persist')), + ); + + final thread = controller.taskThreadForSessionInternal('session-1'); + expect(thread?.lifecycleState.status, 'ready'); + expect(thread?.lastArtifactSyncStatus, 'no-exported-artifacts'); + expect(thread?.lastArtifactSyncAtMs, greaterThan(0)); + }, + ); + test( 'sendChatMessage continues the same session after ACP HTTP handshake interruption', () async { @@ -1036,8 +1111,20 @@ void main() { test( 'sendChatMessage exposes continuing and retrying lifecycle states', () async { - final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService); + late final AppController controller; + final observedRequestStatuses = []; + final fakeGoTaskService = _BlockingGoTaskServiceClient( + onRequest: (request) { + observedRequestStatuses.add( + controller + .taskThreadForSessionInternal(request.sessionId) + ?.lifecycleState + .status ?? + '', + ); + }, + ); + controller = _connectedController(fakeGoTaskService); addTearDown(controller.dispose); await controller.switchSession('interrupted-task'); @@ -1070,10 +1157,10 @@ void main() { final continuingFuture = controller.sendChatMessage('continue'); await fakeGoTaskService.waitForRequestCount(1); - await _waitForThreadLifecycleStatus( - controller, - 'interrupted-task', - 'continuing', + expect(observedRequestStatuses.single, 'continuing'); + expect( + controller.assistantSessionHasPendingRun('interrupted-task'), + isTrue, ); fakeGoTaskService.complete( 'interrupted-task', @@ -1113,11 +1200,8 @@ void main() { final retryFuture = controller.sendChatMessage('retry'); await fakeGoTaskService.waitForRequestCount(2); - await _waitForThreadLifecycleStatus( - controller, - 'retry-task', - 'retrying', - ); + expect(observedRequestStatuses.last, 'retrying'); + expect(controller.assistantSessionHasPendingRun('retry-task'), isTrue); fakeGoTaskService.complete( 'retry-task', const GoTaskServiceResult( @@ -1180,28 +1264,6 @@ Future<_CapabilityServerCapture> _startCapabilityServer() async { return capture; } -Future _waitForThreadLifecycleStatus( - AppController controller, - String sessionKey, - String expectedStatus, -) async { - final deadline = DateTime.now().add(const Duration(seconds: 2)); - while (DateTime.now().isBefore(deadline)) { - final status = controller - .taskThreadForSessionInternal(sessionKey) - ?.lifecycleState - .status; - if (status == expectedStatus) { - return; - } - await Future.delayed(const Duration(milliseconds: 10)); - } - expect( - controller.taskThreadForSessionInternal(sessionKey)?.lifecycleState.status, - expectedStatus, - ); -} - Future _waitForLastChatMessageText( AppController controller, String expectedText, @@ -1384,6 +1446,9 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { } class _BlockingGoTaskServiceClient implements GoTaskServiceClient { + _BlockingGoTaskServiceClient({this.onRequest}); + + final void Function(GoTaskServiceRequest request)? onRequest; final List requests = []; final List cancelledSessionIds = []; final Map> _pending = @@ -1411,6 +1476,7 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient { required void Function(GoTaskServiceUpdate update) onUpdate, }) { requests.add(request); + onRequest?.call(request); _updates[request.sessionId] = onUpdate; final completer = Completer(); _pending[request.sessionId] = completer; @@ -1418,7 +1484,7 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient { } Future waitForRequestCount(int count) async { - final deadline = DateTime.now().add(const Duration(seconds: 2)); + final deadline = DateTime.now().add(const Duration(seconds: 5)); while (requests.length < count && DateTime.now().isBefore(deadline)) { await Future.delayed(const Duration(milliseconds: 10)); }