diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index a912061d..a6d5f844 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -777,6 +777,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { var wroteArtifact = false; var failedArtifact = false; var skippedArtifact = false; + final currentTaskArtifactPaths = {}; 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 []; upsertTaskThreadInternal( normalizedSessionKey, @@ -1238,22 +1250,6 @@ Future> _existingWorkspaceArtifactPathsInternal( return paths; } -Future> _collectWorkspaceArtifactRelativePathsInternal( - Directory root, -) async { - final files = await DesktopThreadArtifactService().collectFilesInternal(root); - final paths = []; - 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) { diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index aa5b610c..25cbdf00 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -460,15 +460,7 @@ extension AppControllerDesktopThreadActions on AppController { final capturedLocalAttachments = List.unmodifiable( localAttachments, ); - final taskLoadClass = classifyGatewayTaskLoadInternal(message); - final expectedArtifactExtensions = - expectedGatewayArtifactExtensionsInternal(message); - final taskMetadata = Map.unmodifiable({ - ...dispatch.metadata, - 'taskLoadClass': taskLoadClass, - if (expectedArtifactExtensions.isNotEmpty) - 'expectedArtifactExtensions': expectedArtifactExtensions, - }); + final taskMetadata = Map.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 = [ - '生成文件', - '产物', - '附件', - '图片提示词', - '完整调研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 expectedGatewayArtifactExtensionsInternal(String requestText) { - final normalized = requestText.trim().toLowerCase(); - final result = []; - - 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.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 [], - 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 [], - 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 abortRun() async { if (multiAgentRunPendingInternal) { final sessionKey = normalizedAssistantSessionKeyInternal( diff --git a/lib/features/assistant/assistant_page_main.dart b/lib/features/assistant/assistant_page_main.dart index 17afc9b9..3d014e39 100644 --- a/lib/features/assistant/assistant_page_main.dart +++ b/lib/features/assistant/assistant_page_main.dart @@ -100,6 +100,9 @@ class AssistantPageStateInternal extends State { {}; final Map composerDraftBySessionKeyInternal = {}; + final Map> + composerAttachmentsBySessionKeyInternal = + >{}; final Set archivedTaskKeysInternal = {}; List attachmentsInternal = const []; @@ -312,6 +315,39 @@ class AssistantPageStateInternal extends State { 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.from( + attachmentsInternal, + growable: false, + ); + } + + void restoreComposerAttachmentsForSessionInternal(String sessionKey) { + final normalizedSessionKey = sessionKey.trim(); + attachmentsInternal = List.from( + composerAttachmentsBySessionKeyInternal[normalizedSessionKey] ?? + const [], + 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 { return; } saveComposerDraftForSessionInternal(composerDraftSessionKeyInternal); + saveComposerAttachmentsForSessionInternal(composerDraftSessionKeyInternal); composerDraftSessionKeyInternal = normalizedSessionKey; restoreComposerDraftForSessionInternal(normalizedSessionKey); + restoreComposerAttachmentsForSessionInternal(normalizedSessionKey); } } diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index 7b314dde..9cd1bcab 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -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 []; + 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 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(); } } diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart index 96ca7d07..6135bdea 100644 --- a/lib/features/assistant/assistant_page_state_closure.dart +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -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: diff --git a/lib/runtime/desktop_thread_artifact_service.dart b/lib/runtime/desktop_thread_artifact_service.dart index d046f301..0cbdadf0 100644 --- a/lib/runtime/desktop_thread_artifact_service.dart +++ b/lib/runtime/desktop_thread_artifact_service.dart @@ -60,10 +60,6 @@ class DesktopThreadArtifactService { final taskArtifactPaths = normalizeTaskArtifactPathsInternal( artifactRelativePaths, ); - final allFiles = taskArtifactPaths.isEmpty - ? const [] - : await collectFilesInternal(root); - final fileEntries = await buildEntriesInternal(allFiles, normalizedRef); final taskFiles = taskArtifactPaths.isEmpty ? const [] : 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 [] : 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, diff --git a/test/features/assistant/assistant_page_session_binding_test.dart b/test/features/assistant/assistant_page_session_binding_test.dart index 8fd1068c..e8043b80 100644 --- a/test/features/assistant/assistant_page_session_binding_test.dart +++ b/test/features/assistant/assistant_page_session_binding_test.dart @@ -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 {}, + ); + addTearDown(controller.dispose); + final pageKey = GlobalKey(); + + 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 [attachmentA]; + + state.syncComposerDraftForActiveSessionInternal('draft-session-b'); + expect(state.attachmentsInternal, isEmpty); + + state.attachmentsInternal = const [attachmentB]; + state.syncComposerDraftForActiveSessionInternal('draft-session-a'); + expect(state.attachmentsInternal, [ + attachmentA, + ]); + + state.syncComposerDraftForActiveSessionInternal('draft-session-b'); + expect(state.attachmentsInternal, [ + 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 { diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index 26112044..1c22fc4a 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -492,14 +492,12 @@ void main() { final snapshot = await controller.loadAssistantArtifactSnapshot( sessionKey: 'unit-fixture-task-a', ); - expect( - snapshot.resultEntries.map((entry) => entry.relativePath), - containsAll(['notes/hello.v2.txt', 'notes/hello.txt']), - ); - expect( - snapshot.fileEntries.map((entry) => entry.relativePath), - containsAll(['notes/hello.v2.txt', 'notes/hello.txt']), - ); + expect(snapshot.resultEntries.map((entry) => entry.relativePath), [ + 'notes/hello.v2.txt', + ]); + expect(snapshot.fileEntries.map((entry) => entry.relativePath), [ + '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 {}, - ); - addTearDown(controller.dispose); + test('keeps task artifacts scoped to the current run', () async { + final controller = AppController( + environmentOverride: const {}, + ); + 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: { - 'artifacts': >[ - { - '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: { + 'artifacts': >[ + { + '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(['current-task-report.md', 'old-task-report.md']), - ); - expect( - snapshot.fileEntries.map((entry) => entry.relativePath), - containsAll(['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, ['current-task-report.md']); + expect(snapshot.fileEntries.map((entry) => entry.relativePath), [ + '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.delayed(const Duration(milliseconds: 10)); } - expect(thread.lastArtifactSyncStatus, 'synced'); + expect( + controller + .requireTaskThreadForSessionInternal('unit-fixture-task-a') + .lastArtifactSyncStatus, + 'synced', + ); }, ); diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 6668fc6d..983cdd2e 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -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'], ['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: {}, + 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: { + '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: {}, + 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( diff --git a/test/runtime/desktop_thread_artifact_service_test.dart b/test/runtime/desktop_thread_artifact_service_test.dart index a797093f..15f23f3a 100644 --- a/test/runtime/desktop_thread_artifact_service_test.dart +++ b/test/runtime/desktop_thread_artifact_service_test.dart @@ -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), ['current.md'], ); - expect( - snapshot.fileEntries.map((entry) => entry.relativePath), - containsAll(['current.md', 'historical.md']), - ); + expect(snapshot.fileEntries.map((entry) => entry.relativePath), [ + '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 [], ); - expect(preview.content, '# Old\n'); + expect(preview.kind, AssistantArtifactPreviewKind.empty); + expect(preview.content, isEmpty); }, ); }