fix(openclaw): keep artifact runs session scoped

This commit is contained in:
Haitao Pan 2026-06-02 00:48:56 +08:00
parent 6d37812c10
commit e0d840d956
10 changed files with 457 additions and 317 deletions

View File

@ -777,6 +777,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
var wroteArtifact = false;
var failedArtifact = false;
var skippedArtifact = false;
final currentTaskArtifactPaths = <String>{};
for (final artifact in artifacts) {
final relativePath = _sanitizeArtifactRelativePathInternal(
artifact.relativePath,
@ -797,6 +798,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
skippedArtifact = true;
continue;
}
currentTaskArtifactPaths.addAll(existingArtifactPaths);
wroteArtifact = true;
continue;
}
@ -811,6 +813,16 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
failedArtifact = true;
continue;
}
final resolvedRelativePath =
DesktopThreadArtifactService.relativePathInternal(
root.path,
target.path,
);
if (resolvedRelativePath == null || resolvedRelativePath.isEmpty) {
failedArtifact = true;
continue;
}
currentTaskArtifactPaths.add(resolvedRelativePath);
wroteArtifact = true;
}
@ -820,7 +832,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
? 'download-failed'
: 'no-artifacts';
final currentTaskArtifactRelativePaths = wroteArtifact
? await _collectWorkspaceArtifactRelativePathsInternal(root)
? (currentTaskArtifactPaths.toList(growable: false)..sort())
: const <String>[];
upsertTaskThreadInternal(
normalizedSessionKey,
@ -1238,22 +1250,6 @@ Future<List<String>> _existingWorkspaceArtifactPathsInternal(
return paths;
}
Future<List<String>> _collectWorkspaceArtifactRelativePathsInternal(
Directory root,
) async {
final files = await DesktopThreadArtifactService().collectFilesInternal(root);
final paths = <String>[];
for (final file in files) {
final resolvedRelativePath =
DesktopThreadArtifactService.relativePathInternal(root.path, file.path);
if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) {
paths.add(resolvedRelativePath);
}
}
paths.sort();
return paths;
}
String _normalizeAuthorizationHeaderInternal(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {

View File

@ -460,15 +460,7 @@ extension AppControllerDesktopThreadActions on AppController {
final capturedLocalAttachments = List<CollaborationAttachment>.unmodifiable(
localAttachments,
);
final taskLoadClass = classifyGatewayTaskLoadInternal(message);
final expectedArtifactExtensions =
expectedGatewayArtifactExtensionsInternal(message);
final taskMetadata = Map<String, dynamic>.unmodifiable(<String, dynamic>{
...dispatch.metadata,
'taskLoadClass': taskLoadClass,
if (expectedArtifactExtensions.isNotEmpty)
'expectedArtifactExtensions': expectedArtifactExtensions,
});
final taskMetadata = Map<String, dynamic>.unmodifiable(dispatch.metadata);
final executionWorkingDirectory = gatewayExecutionWorkingDirectoryInternal(
target: currentTarget,
workingDirectory: workingDirectory,
@ -689,16 +681,6 @@ extension AppControllerDesktopThreadActions on AppController {
..writeln(
'6. The app syncs final artifacts from currentTaskWorkspace back into localWorkspace.',
)
..writeln()
..writeln('Task load classification:')
..writeln('- class: ${classifyGatewayTaskLoadInternal(requestText)}')
..writeln(
'- Gateway owns execution decomposition, scheduling, retries, and resumability for this class.',
)
..writeln()
..writeln(
'Available classes: short_task, long_task, complex_long_chain_task.',
)
..writeln();
buffer
..writeln('User request:')
@ -706,104 +688,6 @@ extension AppControllerDesktopThreadActions on AppController {
return buffer.toString();
}
String classifyGatewayTaskLoadInternal(String requestText) {
final normalized = requestText.trim().toLowerCase();
if (normalized.isEmpty) {
return 'short_task';
}
final hasChapterSplit =
normalized.contains('拆章节') ||
normalized.contains('chapter') ||
normalized.contains('章节');
final hasAgentStage =
normalized.contains('codex') ||
normalized.contains('agent') ||
normalized.contains('调用');
final hasImageStage =
normalized.contains('gpt images') ||
normalized.contains('images2') ||
normalized.contains('生成图') ||
normalized.contains('图片');
final hasPackagingStage =
normalized.contains('汇总排版') ||
normalized.contains('排版') ||
normalized.contains('制作视频') ||
normalized.contains('视频') ||
normalized.contains('mp4');
final hasChainArrows =
normalized.contains('->') || normalized.contains('');
if (hasChapterSplit &&
hasAgentStage &&
hasImageStage &&
hasPackagingStage &&
hasChainArrows) {
return 'complex_long_chain_task';
}
const longTaskMarkers = <String>[
'生成文件',
'产物',
'附件',
'图片提示词',
'完整调研ppt',
'markdown格式',
'输出markdown',
'ppt',
'pptx',
'powerpoint',
'word',
'docx',
'png',
'mp4',
'jpg',
'markdown',
'.md',
'image prompt',
'artifacts',
'downloadurl',
];
if (requestText.length >= 1200 ||
longTaskMarkers.any(normalized.contains)) {
return 'long_task';
}
return 'short_task';
}
List<String> expectedGatewayArtifactExtensionsInternal(String requestText) {
final normalized = requestText.trim().toLowerCase();
final result = <String>[];
void add(String value) {
final normalizedValue = value.trim().toLowerCase().replaceFirst(
RegExp(r'^\.'),
'',
);
if (normalizedValue.isEmpty || result.contains(normalizedValue)) {
return;
}
result.add(normalizedValue);
}
for (final match in RegExp(
r'\.([a-z0-9]{2,5})\b',
caseSensitive: false,
).allMatches(normalized)) {
add(match.group(1) ?? '');
}
for (final match in RegExp(
r'\b([a-z0-9]{2,5})\s*(?:格式|文件|产物|artifact|file|output)',
caseSensitive: false,
).allMatches(normalized)) {
add(match.group(1) ?? '');
}
for (final match in RegExp(
r'(?:输出|导出|生成|制作)\s*([a-z0-9]{2,5})',
caseSensitive: false,
).allMatches(normalized)) {
add(match.group(1) ?? '');
}
return List<String>.unmodifiable(result);
}
bool usesOpenClawGatewayQueueInternal(
AssistantExecutionTarget target,
SingleAgentProvider provider,
@ -1115,7 +999,7 @@ extension AppControllerDesktopThreadActions on AppController {
final noDisplayableOutput =
result.success && assistantText.isEmpty && !hasCurrentRunArtifacts;
final terminalResultCode = noDisplayableOutput
? 'failed'
? 'OPENCLAW_NO_DISPLAYABLE_OUTPUT'
: gatewayTerminalResultCodeInternal(result);
final remoteWorkingDirectory = result.remoteWorkingDirectory.trim();
clearAiGatewayStreamingTextInternal(sessionKey);
@ -1208,41 +1092,17 @@ extension AppControllerDesktopThreadActions on AppController {
required Object error,
}) {
clearAiGatewayStreamingTextInternal(sessionKey);
final unconfirmedConnectCode = unconfirmedAcpHttpConnectCodeInternal(error);
final interruptedTransportCode = interruptedAcpHttpTransportCodeInternal(
error,
);
if (unconfirmedConnectCode != null) {
upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: unconfirmedConnectCode,
lastRemoteWorkingDirectory: '',
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastArtifactSyncStatus: 'failed',
lastTaskArtifactRelativePaths: const <String>[],
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
appendLocalSessionMessageInternal(
sessionKey,
assistantErrorMessageInternal(
gatewayExecutionErrorLabelInternal(error, target: target),
),
persistInThreadContext: true,
);
return;
}
final completedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble();
upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: interruptedTransportCode ?? 'error',
lastRunAtMs: completedAtMs,
lastResultCode: gatewayFailureResultCodeInternal(error),
lastRemoteWorkingDirectory: '',
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastArtifactSyncAtMs: completedAtMs,
lastArtifactSyncStatus: 'failed',
lastTaskArtifactRelativePaths: const <String>[],
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
updatedAtMs: completedAtMs,
);
appendLocalSessionMessageInternal(
sessionKey,
@ -1314,12 +1174,7 @@ extension AppControllerDesktopThreadActions on AppController {
final lastResultCode = taskThreadForSessionInternal(
normalizedSessionKey,
)?.lifecycleState.lastResultCode?.trim().toUpperCase();
return lastResultCode != 'RUNNING' &&
lastResultCode != 'QUEUED' &&
lastResultCode != 'ABORTED' &&
lastResultCode != gatewayAcpHttpConnectTimeoutCode &&
lastResultCode != gatewayAcpHttpConnectFailedCode &&
lastResultCode != gatewayAcpHttpHandshakeInterruptedCode;
return !gatewayResultCodeRequiresNewSessionInternal(lastResultCode ?? '');
}
String gatewayTerminalResultCodeInternal(GoTaskServiceResult result) {
@ -1327,16 +1182,67 @@ extension AppControllerDesktopThreadActions on AppController {
return 'success';
}
final status = result.status.trim();
if (status.isNotEmpty) {
final code = result.code.trim();
if (status.isNotEmpty && status.toLowerCase() != 'failed') {
return status;
}
final code = result.code.trim();
if (code.isNotEmpty) {
return code;
}
if (status.isNotEmpty) {
return status;
}
return 'error';
}
String gatewayFailureResultCodeInternal(Object error) {
final unconfirmedConnectCode = unconfirmedAcpHttpConnectCodeInternal(error);
if (unconfirmedConnectCode != null) {
return unconfirmedConnectCode;
}
final interruptedTransportCode = interruptedAcpHttpTransportCodeInternal(
error,
);
if (interruptedTransportCode != null) {
return interruptedTransportCode;
}
final primaryCode = gatewayExecutionPrimaryCodeInternal(error);
if (primaryCode != null && primaryCode.isNotEmpty) {
return primaryCode;
}
final detailCode = gatewayExecutionDetailCodeInternal(error);
if (detailCode != null && detailCode.isNotEmpty) {
return detailCode;
}
return 'error';
}
bool gatewayResultCodeRequiresNewSessionInternal(String code) {
final normalized = code.trim().toUpperCase();
if (normalized.isEmpty) {
return false;
}
if (normalized == 'RUNNING' ||
normalized == 'QUEUED' ||
normalized == 'ABORTED' ||
normalized == gatewayAcpHttpConnectTimeoutCode ||
normalized == gatewayAcpHttpConnectFailedCode ||
normalized == gatewayAcpHttpHandshakeInterruptedCode ||
normalized == 'BRIDGE_NOT_CONNECTED' ||
normalized == 'ACP_HTTP_401' ||
normalized == 'ACP_HTTP_403' ||
normalized == 'OPENCLAW_GATEWAY_QUEUE_FULL' ||
normalized == 'OPENCLAW_AGENT_FAILED_BEFORE_REPLY' ||
normalized == 'OPENCLAW_NO_DISPLAYABLE_OUTPUT' ||
normalized == 'OPENCLAW_REQUIRED_ARTIFACT_MISSING' ||
normalized == 'OPENCLAW_NO_EXPORTED_ARTIFACTS' ||
normalized == 'OPENCLAW_ARTIFACT_MISSING' ||
normalized == 'ARTIFACT_MISSING') {
return true;
}
return false;
}
Future<void> abortRun() async {
if (multiAgentRunPendingInternal) {
final sessionKey = normalizedAssistantSessionKeyInternal(

View File

@ -100,6 +100,9 @@ class AssistantPageStateInternal extends State<AssistantPage> {
<String, AssistantTaskSeedInternal>{};
final Map<String, String> composerDraftBySessionKeyInternal =
<String, String>{};
final Map<String, List<ComposerAttachmentInternal>>
composerAttachmentsBySessionKeyInternal =
<String, List<ComposerAttachmentInternal>>{};
final Set<String> archivedTaskKeysInternal = <String>{};
List<ComposerAttachmentInternal> attachmentsInternal =
const <ComposerAttachmentInternal>[];
@ -312,6 +315,39 @@ class AssistantPageStateInternal extends State<AssistantPage> {
composerDraftBySessionKeyInternal.remove(normalizedSessionKey);
}
void saveComposerAttachmentsForSessionInternal(String sessionKey) {
final normalizedSessionKey = sessionKey.trim();
if (normalizedSessionKey.isEmpty) {
return;
}
if (attachmentsInternal.isEmpty) {
composerAttachmentsBySessionKeyInternal.remove(normalizedSessionKey);
return;
}
composerAttachmentsBySessionKeyInternal[normalizedSessionKey] =
List<ComposerAttachmentInternal>.from(
attachmentsInternal,
growable: false,
);
}
void restoreComposerAttachmentsForSessionInternal(String sessionKey) {
final normalizedSessionKey = sessionKey.trim();
attachmentsInternal = List<ComposerAttachmentInternal>.from(
composerAttachmentsBySessionKeyInternal[normalizedSessionKey] ??
const <ComposerAttachmentInternal>[],
growable: false,
);
}
void clearComposerAttachmentsForSessionInternal(String sessionKey) {
final normalizedSessionKey = sessionKey.trim();
if (normalizedSessionKey.isEmpty) {
return;
}
composerAttachmentsBySessionKeyInternal.remove(normalizedSessionKey);
}
void syncComposerDraftForActiveSessionInternal(String sessionKey) {
final normalizedSessionKey = sessionKey.trim();
if (normalizedSessionKey.isEmpty ||
@ -319,8 +355,10 @@ class AssistantPageStateInternal extends State<AssistantPage> {
return;
}
saveComposerDraftForSessionInternal(composerDraftSessionKeyInternal);
saveComposerAttachmentsForSessionInternal(composerDraftSessionKeyInternal);
composerDraftSessionKeyInternal = normalizedSessionKey;
restoreComposerDraftForSessionInternal(normalizedSessionKey);
restoreComposerAttachmentsForSessionInternal(normalizedSessionKey);
}
}

View File

@ -68,6 +68,9 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
...attachmentsInternal,
...files.map(ComposerAttachmentInternal.fromXFile),
];
saveComposerAttachmentsForSessionInternal(
widget.controller.currentSessionKey,
);
});
}
@ -128,6 +131,7 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
lastAutoAgentLabelInternal =
autoAgent?.name ?? conversationOwnerLabelInternal(controller);
attachmentsInternal = const <ComposerAttachmentInternal>[];
clearComposerAttachmentsForSessionInternal(submittedSessionKey);
touchTaskSeedInternal(
sessionKey: submittedSessionKey,
title:
@ -167,20 +171,26 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
if (!mounted) {
rethrow;
}
if (!sessionKeysMatchInternal(
final currentSessionMatchesSubmitted = sessionKeysMatchInternal(
widget.controller.currentSessionKey,
submittedSessionKey,
)) {
);
if (!currentSessionMatchesSubmitted) {
composerDraftBySessionKeyInternal[submittedSessionKey] = rawPrompt;
composerAttachmentsBySessionKeyInternal[submittedSessionKey] =
submittedAttachments;
} else if (inputControllerInternal.text.trim().isEmpty) {
inputControllerInternal.value = TextEditingValue(
text: rawPrompt,
selection: TextSelection.collapsed(offset: rawPrompt.length),
);
}
if (attachmentsInternal.isEmpty && submittedAttachments.isNotEmpty) {
if (currentSessionMatchesSubmitted &&
attachmentsInternal.isEmpty &&
submittedAttachments.isNotEmpty) {
setState(() {
attachmentsInternal = submittedAttachments;
saveComposerAttachmentsForSessionInternal(submittedSessionKey);
});
}
rethrow;
@ -427,6 +437,9 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
Future<void> switchSessionWithRetryInternal(String sessionKey) async {
saveComposerDraftForSessionInternal(widget.controller.currentSessionKey);
saveComposerAttachmentsForSessionInternal(
widget.controller.currentSessionKey,
);
final switched = await runTaskSessionActionWithRetryInternal(
appText('切换会话', 'Switch session'),
() => widget.controller.switchSession(sessionKey),
@ -436,6 +449,9 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
restoreComposerDraftForSessionInternal(
widget.controller.currentSessionKey,
);
restoreComposerAttachmentsForSessionInternal(
widget.controller.currentSessionKey,
);
focusComposerInternal();
}
}

View File

@ -235,6 +235,9 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
attachmentsInternal = attachmentsInternal
.where((item) => item.path != attachment.path)
.toList(growable: false);
saveComposerAttachmentsForSessionInternal(
activeSessionKey,
);
});
},
onToggleSkill: (key) {
@ -265,6 +268,9 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
...attachmentsInternal,
attachment,
];
saveComposerAttachmentsForSessionInternal(
activeSessionKey,
);
});
},
onPasteImageAttachment:

View File

@ -60,10 +60,6 @@ class DesktopThreadArtifactService {
final taskArtifactPaths = normalizeTaskArtifactPathsInternal(
artifactRelativePaths,
);
final allFiles = taskArtifactPaths.isEmpty
? const <File>[]
: await collectFilesInternal(root);
final fileEntries = await buildEntriesInternal(allFiles, normalizedRef);
final taskFiles = taskArtifactPaths.isEmpty
? const <File>[]
: await collectTaskArtifactFilesInternal(
@ -71,10 +67,7 @@ class DesktopThreadArtifactService {
normalizedRef,
taskArtifactPaths,
);
final taskFileEntries = await buildEntriesInternal(
taskFiles,
normalizedRef,
);
final fileEntries = await buildEntriesInternal(taskFiles, normalizedRef);
final changes = taskArtifactPaths.isEmpty
? const <AssistantArtifactChangeEntry>[]
: await readGitChangesInternal(
@ -84,19 +77,19 @@ class DesktopThreadArtifactService {
);
final results = await buildResultEntriesInternal(
changes: changes,
fileEntries: taskFileEntries,
fileEntries: fileEntries,
workspacePath: normalizedRef,
);
final resultMessage = results.isEmpty
? taskArtifactPaths.isEmpty
? 'No task artifacts recorded for this run.'
: 'No current task artifacts found. Showing all files for this thread.'
: 'No current task artifacts found for this run.'
: '';
final filesMessage = taskArtifactPaths.isEmpty
? ''
: fileEntries.isEmpty
? 'No files found in the recorded working directory.'
? 'No current task artifact files found in the recorded working directory.'
: '';
final changesMessage = changes.isEmpty
? 'No Git changes found for the current thread workspace.'
@ -139,6 +132,15 @@ class DesktopThreadArtifactService {
'The selected file is not part of the current thread workspace.',
);
}
final taskArtifactPaths = normalizeTaskArtifactPathsInternal(
artifactRelativePaths,
);
if (taskArtifactPaths.isEmpty ||
!taskArtifactPaths.contains(entryRelativePath)) {
return const AssistantArtifactPreview.empty(
message: 'The selected file is not part of the current task artifacts.',
);
}
final targetPath = resolveAbsolutePathInternal(
workspacePath,
entryRelativePath,

View File

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart';
import 'package:xworkmate/features/assistant/assistant_page_main.dart';
import 'package:xworkmate/features/assistant/assistant_page_state_actions.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
@ -118,6 +119,70 @@ void main() {
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('preserves unsent composer attachments per assistant session', (
tester,
) async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
final pageKey = GlobalKey<AssistantPageStateInternal>();
await controller.sessionsController.switchSession('draft-session-a');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Material(
child: SizedBox(
width: 1280,
height: 760,
child: AssistantPage(
key: pageKey,
controller: controller,
showStandaloneTaskRail: false,
onOpenDetail: (_) {},
),
),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
const attachmentA = ComposerAttachmentInternal(
name: 'task-a.png',
path: '/tmp/task-a.png',
icon: Icons.image_outlined,
mimeType: 'image/png',
);
const attachmentB = ComposerAttachmentInternal(
name: 'task-b.md',
path: '/tmp/task-b.md',
icon: Icons.description_outlined,
mimeType: 'text/markdown',
);
final state = pageKey.currentState!;
state.composerDraftSessionKeyInternal = 'draft-session-a';
state.attachmentsInternal = const <ComposerAttachmentInternal>[attachmentA];
state.syncComposerDraftForActiveSessionInternal('draft-session-b');
expect(state.attachmentsInternal, isEmpty);
state.attachmentsInternal = const <ComposerAttachmentInternal>[attachmentB];
state.syncComposerDraftForActiveSessionInternal('draft-session-a');
expect(state.attachmentsInternal, <ComposerAttachmentInternal>[
attachmentA,
]);
state.syncComposerDraftForActiveSessionInternal('draft-session-b');
expect(state.attachmentsInternal, <ComposerAttachmentInternal>[
attachmentB,
]);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('does not scroll when current message metadata refreshes', (
tester,
) async {

View File

@ -492,14 +492,12 @@ void main() {
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'unit-fixture-task-a',
);
expect(
snapshot.resultEntries.map((entry) => entry.relativePath),
containsAll(<String>['notes/hello.v2.txt', 'notes/hello.txt']),
);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
containsAll(<String>['notes/hello.v2.txt', 'notes/hello.txt']),
);
expect(snapshot.resultEntries.map((entry) => entry.relativePath), <String>[
'notes/hello.v2.txt',
]);
expect(snapshot.fileEntries.map((entry) => entry.relativePath), <String>[
'notes/hello.v2.txt',
]);
expect(
controller
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
@ -508,90 +506,83 @@ void main() {
);
});
test(
'keeps current task artifacts primary while exposing older workspace files',
() async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
test('keeps task artifacts scoped to the current run', () async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-isolated-artifact-workspace-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
});
final staleArtifact = File('${localWorkspace.path}/old-task-report.md');
await staleArtifact.writeAsString('stale task output');
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-isolated-artifact-workspace-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
});
final staleArtifact = File('${localWorkspace.path}/old-task-report.md');
await staleArtifact.writeAsString('stale task output');
controller.upsertTaskThreadInternal(
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
writable: true,
),
);
controller.upsertTaskThreadInternal(
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
writable: true,
),
);
final result = GoTaskServiceResult(
success: true,
message: 'hello',
turnId: 'turn-2',
raw: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': 'current-task-report.md',
'content': 'current task output',
'contentType': 'text/markdown',
},
],
},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
);
final result = GoTaskServiceResult(
success: true,
message: 'hello',
turnId: 'turn-2',
raw: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': 'current-task-report.md',
'content': 'current task output',
'contentType': 'text/markdown',
},
],
},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
);
await controller.persistGoTaskArtifactsForSessionInternal(
'unit-fixture-task-a',
result,
);
await controller.persistGoTaskArtifactsForSessionInternal(
'unit-fixture-task-a',
result,
);
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'unit-fixture-task-a',
);
final currentRelativePaths = snapshot.resultEntries
.map((entry) => entry.relativePath)
.toList(growable: false);
expect(
currentRelativePaths,
containsAll(<String>['current-task-report.md', 'old-task-report.md']),
);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
containsAll(<String>['current-task-report.md', 'old-task-report.md']),
);
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'unit-fixture-task-a',
);
final currentRelativePaths = snapshot.resultEntries
.map((entry) => entry.relativePath)
.toList(growable: false);
expect(currentRelativePaths, <String>['current-task-report.md']);
expect(snapshot.fileEntries.map((entry) => entry.relativePath), <String>[
'current-task-report.md',
]);
final stalePreview = await controller.loadAssistantArtifactPreview(
AssistantArtifactEntry(
id: '${localWorkspace.path}::old-task-report.md',
label: 'old-task-report.md',
relativePath: 'old-task-report.md',
kind: AssistantArtifactEntryKind.file,
mimeType: 'text/markdown',
previewable: true,
workspacePath: localWorkspace.path,
),
sessionKey: 'unit-fixture-task-a',
);
expect(stalePreview.kind, AssistantArtifactPreviewKind.markdown);
expect(stalePreview.content, 'stale task output');
},
);
final stalePreview = await controller.loadAssistantArtifactPreview(
AssistantArtifactEntry(
id: '${localWorkspace.path}::old-task-report.md',
label: 'old-task-report.md',
relativePath: 'old-task-report.md',
kind: AssistantArtifactEntryKind.file,
mimeType: 'text/markdown',
previewable: true,
workspacePath: localWorkspace.path,
),
sessionKey: 'unit-fixture-task-a',
);
expect(stalePreview.kind, AssistantArtifactPreviewKind.empty);
expect(stalePreview.content, isEmpty);
});
test('syncs existing workspace directory artifacts recursively', () async {
final controller = AppController(
@ -665,7 +656,6 @@ void main() {
'assets/images/chapters/chapter-1.png',
'assets/images/cover.png',
'chapters/codex-chapter-breakdown.md',
'dist/账户与身份安全演进史-GPT混排最终版.pdf',
]);
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'unit-fixture-task-a',
@ -676,7 +666,6 @@ void main() {
'assets/images/chapters/chapter-1.png',
'assets/images/cover.png',
'chapters/codex-chapter-breakdown.md',
'dist/账户与身份安全演进史-GPT混排最终版.pdf',
]),
);
});
@ -999,17 +988,23 @@ void main() {
await File('${localWorkspace.path}/reports/resume.bin').readAsBytes(),
body,
);
final thread = controller.requireTaskThreadForSessionInternal(
'unit-fixture-task-a',
);
for (
var attempt = 0;
attempt < 20 && thread.lastArtifactSyncStatus != 'synced';
attempt < 20 &&
controller
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus !=
'synced';
attempt += 1
) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
expect(thread.lastArtifactSyncStatus, 'synced');
expect(
controller
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus,
'synced',
);
},
);

View File

@ -1144,7 +1144,7 @@ void main() {
});
test(
'sendChatMessage classifies complex artifact chains for Gateway',
'sendChatMessage leaves Gateway task classification to the remote runtime',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
@ -1162,15 +1162,9 @@ void main() {
expect(fakeGoTaskService.requests, hasLength(1));
final request = fakeGoTaskService.requests.single;
expect(request.metadata['taskLoadClass'], 'complex_long_chain_task');
expect(request.prompt, contains('Task load classification:'));
expect(request.prompt, contains('- class: complex_long_chain_task'));
expect(
request.prompt,
contains(
'Gateway owns execution decomposition, scheduling, retries, and resumability for this class.',
),
);
expect(request.metadata, isNot(contains('taskLoadClass')));
expect(request.metadata, isNot(contains('expectedArtifactExtensions')));
expect(request.prompt, isNot(contains('Task load classification:')));
expect(
request.prompt,
isNot(contains('First write the chapter breakdown')),
@ -1187,7 +1181,7 @@ void main() {
);
test(
'sendChatMessage declares expected artifacts for complex PDF chains',
'sendChatMessage leaves artifact expectations to the remote runtime',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
@ -1206,13 +1200,15 @@ void main() {
expect(fakeGoTaskService.requests, hasLength(1));
final request = fakeGoTaskService.requests.single;
expect(request.metadata['taskLoadClass'], 'complex_long_chain_task');
expect(request.metadata['expectedArtifactExtensions'], <String>['pdf']);
expect(request.metadata, isNot(contains('taskLoadClass')));
expect(request.metadata, isNot(contains('expectedArtifactExtensions')));
expect(request.prompt, isNot(contains('Required final artifact')));
expect(request.prompt, contains('最后 输出 PDF文件'));
},
);
test(
'sendChatMessage classifies simple Gateway prompts as short tasks',
'sendChatMessage sends simple Gateway prompts without local classification',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
@ -1226,27 +1222,33 @@ void main() {
expect(fakeGoTaskService.requests, hasLength(1));
final request = fakeGoTaskService.requests.single;
expect(request.metadata['taskLoadClass'], 'short_task');
expect(request.prompt, contains('- class: short_task'));
expect(request.metadata, isNot(contains('taskLoadClass')));
expect(request.prompt, isNot(contains('- class: short_task')));
expect(request.prompt, contains('User request:\n写一段普通说明'));
},
);
test('sendChatMessage classifies artifact output as a long task', () async {
final fakeGoTaskService = _RecordingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
addTearDown(controller.dispose);
test(
'sendChatMessage sends artifact output without local class metadata',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.ensureActiveAssistantThreadInternal();
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
await controller.sendChatMessage('生成 Markdown 和 PNG 产物');
await controller.ensureActiveAssistantThreadInternal();
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
await controller.sendChatMessage('生成 Markdown 和 PNG 产物');
expect(fakeGoTaskService.requests, hasLength(1));
final request = fakeGoTaskService.requests.single;
expect(request.metadata['taskLoadClass'], 'long_task');
expect(request.prompt, contains('- class: long_task'));
});
expect(fakeGoTaskService.requests, hasLength(1));
final request = fakeGoTaskService.requests.single;
expect(request.metadata, isNot(contains('taskLoadClass')));
expect(request.metadata, isNot(contains('expectedArtifactExtensions')));
expect(request.prompt, isNot(contains('- class: long_task')));
expect(request.prompt, contains('User request:\n生成 Markdown 和 PNG 产物'));
},
);
test(
'sendChatMessage runs Gateway task with remote workspace when local workspace is unavailable',
@ -1530,6 +1532,56 @@ void main() {
},
);
test(
'sendChatMessage starts a new session after ACP HTTP authorization failure',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..outcomes.add(
const GatewayAcpException(
'ACP HTTP request failed (401) · missing bearer authorization',
code: 'ACP_HTTP_401',
),
)
..outcomes.add(
const GoTaskServiceResult(
success: true,
message: 'retried with bridge authorization',
turnId: 'turn-2',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.sendChatMessage('first turn');
expect(fakeGoTaskService.requests, hasLength(1));
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
final failedThread = controller.taskThreadForSessionInternal(
'unit-fixture-task-a',
);
expect(failedThread?.lifecycleState.status, 'ready');
expect(failedThread?.lifecycleState.lastResultCode, 'ACP_HTTP_401');
expect(failedThread?.lastArtifactSyncStatus, 'failed');
await controller.sendChatMessage('retry after auth recovery');
expect(fakeGoTaskService.requests, hasLength(2));
expect(fakeGoTaskService.requests.last.resumeSession, isFalse);
await _waitForLastChatMessageText(
controller,
'retried with bridge authorization',
);
},
);
test(
'sendChatMessage restarts before handling OpenClaw artifact guard results',
() async {
@ -1609,6 +1661,67 @@ void main() {
},
);
test(
'sendChatMessage starts a new session after OpenClaw terminal artifact failure',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..outcomes.add(
const GoTaskServiceResult(
success: false,
message: 'OpenClaw completed without required final artifacts.',
turnId: 'turn-1',
raw: <String, dynamic>{
'status': 'failed',
'code': 'OPENCLAW_REQUIRED_ARTIFACT_MISSING',
},
errorMessage:
'openclaw returned partial artifacts without required final deliverables',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
)
..outcomes.add(
const GoTaskServiceResult(
success: true,
message: 'final artifact delivered',
turnId: 'turn-2',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.sendChatMessage('first turn');
expect(fakeGoTaskService.requests, hasLength(1));
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
final failedThread = controller.taskThreadForSessionInternal(
'unit-fixture-task-a',
);
expect(
failedThread?.lifecycleState.lastResultCode,
'OPENCLAW_REQUIRED_ARTIFACT_MISSING',
);
expect(failedThread?.lastArtifactSyncStatus, 'failed');
await controller.sendChatMessage('retry final artifact');
expect(fakeGoTaskService.requests, hasLength(2));
expect(fakeGoTaskService.requests.last.resumeSession, isFalse);
await _waitForLastChatMessageText(
controller,
'final artifact delivered',
);
},
);
test(
'sendChatMessage hides OpenClaw artifact guard text from failed results and streaming',
() async {
@ -2626,7 +2739,10 @@ void main() {
final thread = controller.requireTaskThreadForSessionInternal(
'empty-output-task',
);
expect(thread.lifecycleState.lastResultCode, 'failed');
expect(
thread.lifecycleState.lastResultCode,
'OPENCLAW_NO_DISPLAYABLE_OUTPUT',
);
expect(thread.lastArtifactSyncStatus, 'failed');
expect(thread.lastTaskArtifactRelativePaths, isEmpty);
final snapshot = await controller.loadAssistantArtifactSnapshot(

View File

@ -36,7 +36,7 @@ void main() {
);
test(
'loadSnapshot keeps current task artifacts separate from all files',
'loadSnapshot keeps the file list scoped to current task artifacts',
() async {
final workspace = await Directory.systemTemp.createTemp(
'xworkmate-artifact-snapshot-',
@ -59,15 +59,14 @@ void main() {
snapshot.resultEntries.map((entry) => entry.relativePath),
<String>['current.md'],
);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
containsAll(<String>['current.md', 'historical.md']),
);
expect(snapshot.fileEntries.map((entry) => entry.relativePath), <String>[
'current.md',
]);
},
);
test(
'loadPreview allows user-selected historical files in the thread workspace',
'loadPreview rejects historical files outside current task artifacts',
() async {
final workspace = await Directory.systemTemp.createTemp(
'xworkmate-artifact-preview-',
@ -95,7 +94,8 @@ void main() {
artifactRelativePaths: const <String>[],
);
expect(preview.content, '# Old\n');
expect(preview.kind, AssistantArtifactPreviewKind.empty);
expect(preview.content, isEmpty);
},
);
}