fix(openclaw): keep artifact runs session scoped
This commit is contained in:
parent
6d37812c10
commit
e0d840d956
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user