diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 3ab9ec39..275c60a6 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -350,6 +350,7 @@ class AppController extends ChangeNotifier { int openClawGatewayActiveTasksInternal = 0; bool multiAgentRunPendingInternal = false; int localMessageCounterInternal = 0; + int assistantDraftSessionCounterInternal = 0; WorkspaceDestination destinationInternal = WorkspaceDestination.assistant; ThemeMode themeModeInternal = ThemeMode.light; diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index e59ea005..64f01429 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -124,14 +124,20 @@ extension AppControllerDesktopRuntimeHelpers on AppController { assistantThreadTurnQueuesInternal[normalizedThreadId] ?? Future.value(); final completer = Completer(); + T? result; + Object? failure; + StackTrace? failureStackTrace; + var taskCompleted = false; late final Future next; next = previous .catchError((_) {}) .then((_) async { try { - completer.complete(await task()); + result = await task(); + taskCompleted = true; } catch (error, stackTrace) { - completer.completeError(error, stackTrace); + failure = error; + failureStackTrace = stackTrace; } }) .whenComplete(() { @@ -141,6 +147,25 @@ extension AppControllerDesktopRuntimeHelpers on AppController { )) { assistantThreadTurnQueuesInternal.remove(normalizedThreadId); } + if (completer.isCompleted) { + return; + } + final error = failure; + if (error != null) { + completer.completeError( + error, + failureStackTrace ?? StackTrace.current, + ); + return; + } + if (taskCompleted) { + completer.complete(result); + return; + } + completer.completeError( + StateError('Thread turn did not complete.'), + StackTrace.current, + ); }); assistantThreadTurnQueuesInternal[normalizedThreadId] = next; return completer.future; diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 30768785..7662f710 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -181,12 +181,25 @@ extension AppControllerDesktopThreadActions on AppController { } Future refreshSessions() async { + final selectedSessionKey = normalizedAssistantSessionKeyInternal( + sessionsControllerInternal.currentSessionKey, + ); + final preserveSelectedLocalTask = + !isAssistantTaskArchived(selectedSessionKey) && + hasAssistantTaskStateInternal(selectedSessionKey); sessionsControllerInternal.configure( mainSessionKey: runtimeInternal.snapshot.mainSessionKey ?? 'main', selectedAgentId: agentsControllerInternal.selectedAgentId, defaultAgentId: '', ); await sessionsControllerInternal.refresh(); + if (preserveSelectedLocalTask && + !matchesSessionKey( + selectedSessionKey, + sessionsControllerInternal.currentSessionKey, + )) { + await sessionsControllerInternal.switchSession(selectedSessionKey); + } await chatControllerInternal.loadSession( sessionsControllerInternal.currentSessionKey, ); @@ -236,6 +249,9 @@ extension AppControllerDesktopThreadActions on AppController { sessionsControllerInternal.currentSessionKey, ); final currentTarget = assistantExecutionTargetForSession(sessionKey); + final resumeSessionHint = shouldResumeGatewaySessionForNextSendInternal( + sessionKey, + ); var connectionState = assistantConnectionStateForSession(sessionKey); if (!connectionState.connected && isBridgeAcpRuntimeConfiguredInternal() && @@ -356,6 +372,7 @@ extension AppControllerDesktopThreadActions on AppController { routing: routing, agentId: dispatch.agentId ?? '', metadata: Map.unmodifiable(dispatch.metadata), + resumeSessionHint: resumeSessionHint, ), ); return; @@ -377,6 +394,7 @@ extension AppControllerDesktopThreadActions on AppController { routing: routing, agentId: dispatch.agentId ?? '', metadata: Map.unmodifiable(dispatch.metadata), + resumeSessionHint: resumeSessionHint, ), ); recomputeTasksInternal(); @@ -397,10 +415,11 @@ extension AppControllerDesktopThreadActions on AppController { required ExternalCodeAgentAcpRoutingConfig routing, required String agentId, required Map metadata, + required bool resumeSessionHint, }) async { - final resumeSession = shouldResumeGatewaySessionForNextSendInternal( - sessionKey, - ); + final resumeSession = + resumeSessionHint || + shouldResumeGatewaySessionForNextSendInternal(sessionKey); appendGatewayUserTurnInternal(sessionKey, message); markGatewayChatRunInternal(sessionKey); try { @@ -500,11 +519,15 @@ extension AppControllerDesktopThreadActions on AppController { } void markOpenClawGatewayQueuedTurnInternal(String sessionKey) { + final queuedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); upsertTaskThreadInternal( sessionKey, lifecycleStatus: 'queued', lastResultCode: 'queued', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastArtifactSyncAtMs: queuedAtMs, + lastArtifactSyncStatus: 'queued', + lastTaskArtifactRelativePaths: const [], + updatedAtMs: queuedAtMs, ); recomputeTasksInternal(); notifyIfActiveInternal(); @@ -519,6 +542,7 @@ extension AppControllerDesktopThreadActions on AppController { lifecycleStatus: 'ready', lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), lastResultCode: 'OPENCLAW_GATEWAY_QUEUE_FULL', + lastRemoteWorkingDirectory: '', lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), lastArtifactSyncStatus: 'failed', lastTaskArtifactRelativePaths: const [], @@ -555,6 +579,10 @@ extension AppControllerDesktopThreadActions on AppController { lifecycleStatus: 'ready', lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), lastResultCode: 'aborted', + lastRemoteWorkingDirectory: '', + lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastArtifactSyncStatus: 'failed', + lastTaskArtifactRelativePaths: const [], updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); if (!turn.completer.isCompleted) { @@ -610,6 +638,7 @@ extension AppControllerDesktopThreadActions on AppController { routing: turn.routing, agentId: turn.agentId, metadata: turn.metadata, + resumeSessionHint: turn.resumeSessionHint, ), ); if (!turn.completer.isCompleted) { @@ -645,23 +674,50 @@ extension AppControllerDesktopThreadActions on AppController { } void markGatewayChatRunInternal(String sessionKey) { + final startedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); aiGatewayPendingSessionKeysInternal.add(sessionKey); upsertTaskThreadInternal( sessionKey, lifecycleStatus: 'running', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastRunAtMs: startedAtMs, lastResultCode: 'running', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastArtifactSyncAtMs: startedAtMs, + lastArtifactSyncStatus: 'running', + lastTaskArtifactRelativePaths: const [], + updatedAtMs: startedAtMs, ); recomputeTasksInternal(); notifyIfActiveInternal(); } + void clearGatewayTaskArtifactStateInternal( + String sessionKey, { + required double completedAtMs, + required String syncStatus, + }) { + upsertTaskThreadInternal( + sessionKey, + lastArtifactSyncAtMs: completedAtMs, + lastArtifactSyncStatus: syncStatus, + lastTaskArtifactRelativePaths: const [], + updatedAtMs: completedAtMs, + ); + } + Future applyGatewayChatResultInternal({ required String sessionKey, required AssistantExecutionTarget target, required GoTaskServiceResult result, }) async { + final completedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + final assistantText = result.message.trim(); + final hasCurrentRunArtifacts = result.artifacts.isNotEmpty; + final noDisplayableOutput = + result.success && assistantText.isEmpty && !hasCurrentRunArtifacts; + final terminalResultCode = noDisplayableOutput + ? 'failed' + : gatewayTerminalResultCodeInternal(result); + final remoteWorkingDirectory = result.remoteWorkingDirectory.trim(); clearAiGatewayStreamingTextInternal(sessionKey); upsertTaskThreadInternal( sessionKey, @@ -670,21 +726,25 @@ extension AppControllerDesktopThreadActions on AppController { result: result, ), latestResolvedRuntimeModel: result.resolvedModel.trim(), - lastRemoteWorkingDirectory: - result.remoteWorkingDirectory.trim().isNotEmpty - ? result.remoteWorkingDirectory.trim() - : null, + lastRemoteWorkingDirectory: remoteWorkingDirectory.isNotEmpty + ? remoteWorkingDirectory + : '', lastRemoteWorkspaceRefKind: result.remoteWorkspaceRefKind, lifecycleStatus: 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: gatewayTerminalResultCodeInternal(result), - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastRunAtMs: completedAtMs, + lastResultCode: terminalResultCode, + updatedAtMs: completedAtMs, ); if (isOpenClawNoExportedArtifactsGuardResultInternal(result)) { await persistGoTaskArtifactsForSessionInternal(sessionKey, result); return; } if (!result.success) { + clearGatewayTaskArtifactStateInternal( + sessionKey, + completedAtMs: completedAtMs, + syncStatus: 'failed', + ); appendLocalSessionMessageInternal( sessionKey, assistantErrorMessageInternal( @@ -702,8 +762,12 @@ extension AppControllerDesktopThreadActions on AppController { ); return; } - final assistantText = result.message.trim(); - if (assistantText.isEmpty) { + if (noDisplayableOutput) { + clearGatewayTaskArtifactStateInternal( + sessionKey, + completedAtMs: completedAtMs, + syncStatus: 'failed', + ); appendLocalSessionMessageInternal( sessionKey, assistantErrorMessageInternal( @@ -716,28 +780,23 @@ extension AppControllerDesktopThreadActions on AppController { ); return; } - appendLocalSessionMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: assistantText, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - persistInThreadContext: true, - ); - upsertTaskThreadInternal( - sessionKey, - lifecycleStatus: 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: gatewayTerminalResultCodeInternal(result), - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); + if (assistantText.isNotEmpty) { + appendLocalSessionMessageInternal( + sessionKey, + GatewayChatMessage( + id: nextLocalMessageIdInternal(), + role: 'assistant', + text: assistantText, + timestampMs: completedAtMs, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + persistInThreadContext: true, + ); + } recomputeTasksInternal(); notifyIfActiveInternal(); await persistGoTaskArtifactsForSessionInternal(sessionKey, result); @@ -759,6 +818,10 @@ extension AppControllerDesktopThreadActions on AppController { 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( @@ -775,6 +838,7 @@ extension AppControllerDesktopThreadActions on AppController { lifecycleStatus: 'ready', lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), lastResultCode: interruptedTransportCode ?? 'error', + lastRemoteWorkingDirectory: '', lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), lastArtifactSyncStatus: 'failed', lastTaskArtifactRelativePaths: const [], @@ -853,6 +917,10 @@ extension AppControllerDesktopThreadActions on AppController { lifecycleStatus: 'ready', lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), lastResultCode: 'aborted', + lastRemoteWorkingDirectory: '', + lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastArtifactSyncStatus: 'failed', + lastTaskArtifactRelativePaths: const [], updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); recomputeTasksInternal(); @@ -879,6 +947,10 @@ extension AppControllerDesktopThreadActions on AppController { lifecycleStatus: 'ready', lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), lastResultCode: 'aborted', + lastRemoteWorkingDirectory: '', + lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastArtifactSyncStatus: 'failed', + lastTaskArtifactRelativePaths: const [], updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); recomputeTasksInternal(); diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index e71612bb..86d65605 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -187,12 +187,18 @@ bool bridgeCapabilityReadyForExecutionTargetInternal({ extension AppControllerDesktopThreadSessions on AppController { AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordsInternal( - TaskThread? primaryRecord, { - TaskThread? fallbackRecord, - }) { - return resolveAssistantExecutionTargetFromRecordsForTest( - primaryRecord, - fallbackRecord: fallbackRecord, + TaskThread? record, + ) { + return resolveAssistantExecutionTargetFromRecordForTest( + record, + defaultExecutionTarget: pickDraftThreadExecutionTargetInternal( + currentTarget: sanitizePersistedExecutionTargetInternal( + settings.assistantExecutionTarget, + ), + visibleTargets: visibleAssistantExecutionTargets( + AssistantExecutionTarget.values, + ), + ), ); } @@ -214,6 +220,31 @@ extension AppControllerDesktopThreadSessions on AppController { ); } + bool hasAssistantTaskStateInternal(String sessionKey) { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + return taskThreadRepositoryInternal.containsKey(normalizedSessionKey) || + assistantThreadMessagesInternal.containsKey(normalizedSessionKey) || + localSessionMessagesInternal.containsKey(normalizedSessionKey); + } + + String createAssistantDraftSessionKeyInternal() { + final selectedAgentId = agentsControllerInternal.selectedAgentId.trim(); + for (var attempt = 0; attempt < 32; attempt += 1) { + assistantDraftSessionCounterInternal += 1; + final stamp = DateTime.now().microsecondsSinceEpoch; + final suffix = '$stamp-$assistantDraftSessionCounterInternal'; + final candidate = selectedAgentId.isEmpty + ? 'draft:$suffix' + : 'draft:$selectedAgentId:$suffix'; + if (!hasAssistantTaskStateInternal(candidate)) { + return candidate; + } + } + throw StateError('Unable to allocate a unique draft task session key.'); + } + List assistantSelectedSkillKeysForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, @@ -535,13 +566,7 @@ extension AppControllerDesktopThreadSessions on AppController { sessionKey, ); final record = taskThreadForSessionInternal(normalizedSessionKey); - final mainRecord = matchesSessionKey(normalizedSessionKey, 'main') - ? null - : taskThreadForSessionInternal('main'); - return resolveAssistantExecutionTargetFromRecordsInternal( - record, - fallbackRecord: mainRecord, - ); + return resolveAssistantExecutionTargetFromRecordsInternal(record); } AssistantMessageViewMode assistantMessageViewModeForSession( @@ -605,13 +630,12 @@ extension AppControllerDesktopThreadSessions on AppController { } } -AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordsForTest( - TaskThread? primaryRecord, { - TaskThread? fallbackRecord, +AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordForTest( + TaskThread? record, { + required AssistantExecutionTarget defaultExecutionTarget, }) { - final record = primaryRecord ?? fallbackRecord; return record == null - ? AssistantExecutionTarget.agent + ? defaultExecutionTarget : assistantExecutionTargetFromExecutionMode( record.executionBinding.executionMode, ); diff --git a/lib/app/app_controller_openclaw_task_queue.dart b/lib/app/app_controller_openclaw_task_queue.dart index 7c76fa3c..dc2ccad6 100644 --- a/lib/app/app_controller_openclaw_task_queue.dart +++ b/lib/app/app_controller_openclaw_task_queue.dart @@ -23,6 +23,7 @@ class OpenClawGatewayQueuedTurnInternal { required this.routing, required this.agentId, required this.metadata, + required this.resumeSessionHint, }); final String queueId; @@ -40,6 +41,7 @@ class OpenClawGatewayQueuedTurnInternal { final ExternalCodeAgentAcpRoutingConfig routing; final String agentId; final Map metadata; + final bool resumeSessionHint; final Completer completer = Completer(); bool cancelled = false; diff --git a/lib/app/app_shell_desktop.dart b/lib/app/app_shell_desktop.dart index e552541b..d28d45a9 100644 --- a/lib/app/app_shell_desktop.dart +++ b/lib/app/app_shell_desktop.dart @@ -82,7 +82,7 @@ class _AppShellState extends State { AppController controller, List visibleTargets, ) async { - final sessionKey = 'draft:${DateTime.now().millisecondsSinceEpoch}'; + final sessionKey = controller.createAssistantDraftSessionKeyInternal(); final target = pickDraftThreadExecutionTargetInternal( currentTarget: controller.currentAssistantExecutionTarget, visibleTargets: visibleTargets, @@ -314,10 +314,9 @@ class _AppShellState extends State { onExpandFromCollapsed: () => _toggleSidebarVisibility(controller), onOpenHome: controller.navigateHome, - onOpenAccount: () => - controller.openSettings( - tab: SettingsTab.gateway, - ), + onOpenAccount: () => controller.openSettings( + tab: SettingsTab.gateway, + ), onOpenThemeToggle: () => controller.setThemeMode( controller.themeMode == ThemeMode.dark diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index 72dc5e95..7ddbd39b 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -407,7 +407,8 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { } Future createNewThreadInternal() async { - final sessionKey = buildDraftSessionKeyInternal(widget.controller); + final sessionKey = widget.controller + .createAssistantDraftSessionKeyInternal(); final inheritedTarget = pickDraftThreadExecutionTargetInternal( currentTarget: widget.controller.currentAssistantExecutionTarget, visibleTargets: widget.controller.visibleAssistantExecutionTargets( @@ -798,15 +799,6 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { }); } - String buildDraftSessionKeyInternal(AppController controller) { - final stamp = DateTime.now().millisecondsSinceEpoch; - final selectedAgentId = controller.selectedAgentId.trim(); - if (selectedAgentId.isEmpty) { - return 'draft:$stamp'; - } - return 'draft:$selectedAgentId:$stamp'; - } - AssistantFocusEntry? resolveFocusedDestinationInternal( List favorites, ) { diff --git a/lib/runtime/runtime_controllers_gateway.dart b/lib/runtime/runtime_controllers_gateway.dart index 12a993ca..3fb26b2f 100644 --- a/lib/runtime/runtime_controllers_gateway.dart +++ b/lib/runtime/runtime_controllers_gateway.dart @@ -194,9 +194,9 @@ class GatewayChatController extends ChangeNotifier { Future loadSession(String sessionKey) async { final next = sessionKey.trim().isEmpty ? 'main' : sessionKey.trim(); sessionKeyInternal = next; + messagesInternal = const []; + streamingAssistantTextInternal = null; if (!runtimeInternal.isConnected) { - messagesInternal = const []; - streamingAssistantTextInternal = null; errorInternal = null; notifyListeners(); return; @@ -206,8 +206,8 @@ class GatewayChatController extends ChangeNotifier { notifyListeners(); try { messagesInternal = await runtimeInternal.loadHistory(next); - streamingAssistantTextInternal = null; } catch (error) { + messagesInternal = const []; errorInternal = error.toString(); } finally { loadingInternal = false; diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index f67afbbb..f7593bfe 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -108,6 +108,80 @@ void main() { }, ); + test( + 'new task sessions do not inherit execution target from main', + () async { + final localHome = await Directory.systemTemp.createTemp( + 'xworkmate-no-main-target-inheritance-', + ); + addTearDown(() async { + if (await localHome.exists()) { + await localHome.delete(recursive: true); + } + }); + final controller = AppController( + environmentOverride: const {}, + initialBridgeProviderCatalog: const [ + SingleAgentProvider.codex, + ], + initialGatewayProviderCatalog: const [ + SingleAgentProvider.openclaw, + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + ); + addTearDown(controller.dispose); + controller.resolvedUserHomeDirectoryInternal = localHome.path; + + controller.upsertTaskThreadInternal( + 'main', + executionTarget: AssistantExecutionTarget.gateway, + selectedProvider: SingleAgentProvider.openclaw, + selectedProviderSource: ThreadSelectionSource.explicit, + ); + + expect( + controller.assistantExecutionTargetForSession('fresh-task'), + AssistantExecutionTarget.agent, + ); + + await controller.switchSession('fresh-task'); + + final freshThread = controller.requireTaskThreadForSessionInternal( + 'fresh-task', + ); + expect( + freshThread.executionBinding.executionMode, + ThreadExecutionMode.agent, + ); + expect( + freshThread.workspaceBinding.workspacePath, + endsWith('/.xworkmate/threads/fresh-task'), + ); + }, + ); + + test('allocates unique draft session keys for repeated task creation', () { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final first = controller.createAssistantDraftSessionKeyInternal(); + controller.initializeAssistantThreadContext( + first, + executionTarget: AssistantExecutionTarget.agent, + messageViewMode: AssistantMessageViewMode.rendered, + ); + final second = controller.createAssistantDraftSessionKeyInternal(); + + expect(first, startsWith('draft:')); + expect(second, startsWith('draft:')); + expect(second, isNot(first)); + }); + test( 'returns unspecified when a saved provider is no longer in the current catalog', () { @@ -762,7 +836,8 @@ void main() { failedThread?.lifecycleState.lastResultCode, gatewayAcpHttpConnectTimeoutCode, ); - expect(failedThread?.lastArtifactSyncStatus, isNull); + expect(failedThread?.lastArtifactSyncStatus, 'failed'); + expect(failedThread?.lastTaskArtifactRelativePaths, isEmpty); expect( controller.chatMessages.last.text, 'Bridge 连接超时,本轮请求未确认,可重试。错误码:ACP_HTTP_CONNECT_TIMEOUT', @@ -1418,6 +1493,224 @@ void main() { }, ); + test( + 'sendChatMessage accepts artifact-only task success as terminal output', + () async { + final localHome = await Directory.systemTemp.createTemp( + 'xworkmate-artifact-only-home-', + ); + addTearDown(() async { + if (await localHome.exists()) { + await localHome.delete(recursive: true); + } + }); + final fakeGoTaskService = _BlockingGoTaskServiceClient(); + final controller = _connectedController(fakeGoTaskService); + addTearDown(controller.dispose); + controller.resolvedUserHomeDirectoryInternal = localHome.path; + + await controller.switchSession('artifact-only-task'); + final taskFuture = controller.sendChatMessage('create only a file'); + await fakeGoTaskService.waitForRequestCount(1); + fakeGoTaskService.complete( + 'artifact-only-task', + const GoTaskServiceResult( + success: true, + message: '', + turnId: 'turn-artifact-only', + raw: { + 'artifacts': >[ + { + 'relativePath': 'artifact-only.md', + 'content': 'artifact-only body', + 'contentType': 'text/markdown', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await taskFuture; + + final workspacePath = controller.assistantWorkspacePathForSession( + 'artifact-only-task', + ); + final thread = controller.requireTaskThreadForSessionInternal( + 'artifact-only-task', + ); + expect(thread.lifecycleState.lastResultCode, 'success'); + expect(thread.lastArtifactSyncStatus, 'synced'); + expect(thread.lastTaskArtifactRelativePaths, hasLength(1)); + final recordedPath = thread.lastTaskArtifactRelativePaths.single; + expect(recordedPath, matches(RegExp(r'^artifact-only(\.v\d+)?\.md$'))); + expect( + await File('$workspacePath/$recordedPath').readAsString(), + 'artifact-only body', + ); + expect( + controller.localSessionMessagesInternal['artifact-only-task']!.where( + (message) => message.error, + ), + isEmpty, + ); + }, + ); + + test( + 'sendChatMessage clears stale current artifacts on terminal task failure', + () async { + final localHome = await Directory.systemTemp.createTemp( + 'xworkmate-terminal-failure-home-', + ); + addTearDown(() async { + if (await localHome.exists()) { + await localHome.delete(recursive: true); + } + }); + final fakeGoTaskService = _BlockingGoTaskServiceClient(); + final controller = _connectedController(fakeGoTaskService); + addTearDown(controller.dispose); + controller.resolvedUserHomeDirectoryInternal = localHome.path; + + await controller.switchSession('terminal-failure-task'); + final firstFuture = controller.sendChatMessage('create first file'); + await fakeGoTaskService.waitForRequestCount(1); + fakeGoTaskService.complete( + 'terminal-failure-task', + const GoTaskServiceResult( + success: true, + message: 'first result', + turnId: 'turn-first', + raw: { + 'artifacts': >[ + { + 'relativePath': 'first.md', + 'content': 'first body', + 'contentType': 'text/markdown', + }, + ], + 'remoteWorkingDirectory': '/remote/first-run', + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await firstFuture; + + final secondFuture = controller.sendChatMessage('second run fails'); + await fakeGoTaskService.waitForRequestCount(2); + fakeGoTaskService.complete( + 'terminal-failure-task', + const GoTaskServiceResult( + success: false, + message: '', + turnId: 'turn-second', + raw: {'status': 'failed'}, + errorMessage: 'second run failed', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await secondFuture; + + final thread = controller.requireTaskThreadForSessionInternal( + 'terminal-failure-task', + ); + expect(thread.lifecycleState.lastResultCode, 'failed'); + expect(thread.lastArtifactSyncStatus, 'failed'); + expect(thread.lastTaskArtifactRelativePaths, isEmpty); + expect(thread.lastRemoteWorkingDirectory?.trim(), isEmpty); + + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: 'terminal-failure-task', + ); + expect(snapshot.resultEntries, isEmpty); + expect( + snapshot.fileEntries.map((entry) => entry.relativePath), + contains('first.md'), + ); + }, + ); + + test( + 'sendChatMessage clears stale current artifacts when output is empty', + () async { + final localHome = await Directory.systemTemp.createTemp( + 'xworkmate-empty-output-home-', + ); + addTearDown(() async { + if (await localHome.exists()) { + await localHome.delete(recursive: true); + } + }); + final fakeGoTaskService = _BlockingGoTaskServiceClient(); + final controller = _connectedController(fakeGoTaskService); + addTearDown(controller.dispose); + controller.resolvedUserHomeDirectoryInternal = localHome.path; + + await controller.switchSession('empty-output-task'); + final firstFuture = controller.sendChatMessage('create first file'); + await fakeGoTaskService.waitForRequestCount(1); + fakeGoTaskService.complete( + 'empty-output-task', + const GoTaskServiceResult( + success: true, + message: 'first result', + turnId: 'turn-first', + raw: { + 'artifacts': >[ + { + 'relativePath': 'first.md', + 'content': 'first body', + 'contentType': 'text/markdown', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await firstFuture; + + final secondFuture = controller.sendChatMessage('empty run'); + await fakeGoTaskService.waitForRequestCount(2); + fakeGoTaskService.complete( + 'empty-output-task', + const GoTaskServiceResult( + success: true, + message: '', + turnId: 'turn-second', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await secondFuture; + + final thread = controller.requireTaskThreadForSessionInternal( + 'empty-output-task', + ); + expect(thread.lifecycleState.lastResultCode, 'failed'); + expect(thread.lastArtifactSyncStatus, 'failed'); + expect(thread.lastTaskArtifactRelativePaths, isEmpty); + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: 'empty-output-task', + ); + expect(snapshot.resultEntries, isEmpty); + expect( + controller.localSessionMessagesInternal['empty-output-task']!.any( + (message) => message.error && message.text.contains('没有返回可显示的输出'), + ), + isTrue, + ); + }, + ); + test('abortRun cancels only the current pending session', () async { final fakeGoTaskService = _BlockingGoTaskServiceClient(); final controller = _connectedController(fakeGoTaskService); @@ -1681,6 +1974,7 @@ void main() { ), agentId: '', metadata: const {}, + resumeSessionHint: false, ); controller.openClawGatewayQueuedTurnsInternal.add(turn); controller.openClawGatewayQueuedTurnsBySessionInternal[sessionKey] =