fix: hide OpenClaw artifact guard diagnostics
This commit is contained in:
parent
828c3bac35
commit
db88b10ee0
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
}) {
|
||||
|
||||
@ -342,6 +342,10 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
|
||||
.assistantArtifactSyncAtMsForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
artifactSyncStatus: controller
|
||||
.assistantArtifactSyncStatusForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
onCollapse: () {
|
||||
setState(() {
|
||||
artifactPaneCollapsedInternal = true;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>{
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user