fix: hide OpenClaw artifact guard diagnostics

This commit is contained in:
Haitao Pan 2026-05-08 18:14:36 +08:00
parent 828c3bac35
commit db88b10ee0
8 changed files with 309 additions and 36 deletions

View File

@ -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;

View File

@ -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(

View File

@ -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<AssistantArtifactSnapshot> loadAssistantArtifactSnapshot({
String? sessionKey,
}) {

View File

@ -342,6 +342,10 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
.assistantArtifactSyncAtMsForSession(
controller.currentSessionKey,
),
artifactSyncStatus: controller
.assistantArtifactSyncStatusForSession(
controller.currentSessionKey,
),
onCollapse: () {
setState(() {
artifactPaneCollapsedInternal = true;

View File

@ -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<AssistantArtifactSidebar> {
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<AssistantArtifactSidebar> {
}
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;

View File

@ -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<AssistantArtifactSnapshot> Function() loadSnapshot,
Future<AssistantArtifactPreview> Function(AssistantArtifactEntry entry)?
loadPreview,
@ -172,6 +221,7 @@ Widget _buildTestApp({
workspacePath: '/tmp/thread',
workspaceKind: WorkspaceRefKind.localPath,
artifactSyncAtMs: artifactSyncAtMs,
artifactSyncStatus: artifactSyncStatus,
onCollapse: () {},
loadSnapshot: loadSnapshot,
loadPreview:

View File

@ -829,6 +829,106 @@ void main() {
);
});
test(
'records OpenClaw guard status without creating pseudo artifact files',
() async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
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: <String, dynamic>{'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 <String, String>{},
);
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: <String, dynamic>{},
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 <String, String>{

View File

@ -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: <String, dynamic>{},
),
)
..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: <String, dynamic>{'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 = <String>[];
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<void> _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<void>.delayed(const Duration(milliseconds: 10));
}
expect(
controller.taskThreadForSessionInternal(sessionKey)?.lifecycleState.status,
expectedStatus,
);
}
Future<void> _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<GoTaskServiceRequest> requests = <GoTaskServiceRequest>[];
final List<String> cancelledSessionIds = <String>[];
final Map<String, Completer<GoTaskServiceResult>> _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<GoTaskServiceResult>();
_pending[request.sessionId] = completer;
@ -1418,7 +1484,7 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient {
}
Future<void> 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<void>.delayed(const Duration(milliseconds: 10));
}