diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index 56c31aa3..9bbcdb73 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -223,12 +223,14 @@ String? resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal( } CodeAgentNodeState buildCodeAgentNodeStateRuntimeInternal( - AppController controller, -) { + AppController controller, { + AssistantExecutionTarget? executionTarget, +}) { return CodeAgentNodeState( selectedAgentId: controller.agentsControllerInternal.selectedAgentId, gatewayConnected: controller.runtimeInternal.isConnected, - executionTarget: controller.currentAssistantExecutionTarget, + executionTarget: + executionTarget ?? controller.currentAssistantExecutionTarget, runtimeMode: controller.effectiveCodeAgentRuntimeMode, bridgeEnabled: controller.isCodexBridgeEnabledInternal, bridgeState: controller.codexCooperationStateInternal.name, diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index fa1cacc7..9aa64ae8 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -603,8 +603,12 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ); } - CodeAgentNodeState buildCodeAgentNodeStateInternal() => - buildCodeAgentNodeStateRuntimeInternal(this); + CodeAgentNodeState buildCodeAgentNodeStateInternal({ + AssistantExecutionTarget? executionTarget, + }) => buildCodeAgentNodeStateRuntimeInternal( + this, + executionTarget: executionTarget, + ); GatewayMode bridgeGatewayModeInternal() => bridgeGatewayModeRuntimeInternal(this); diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index e948b7d3..b44e06d9 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -231,9 +231,11 @@ extension AppControllerDesktopThreadActions on AppController { const [], List selectedSkillLabels = const [], }) async { - final currentSessionKey = sessionsControllerInternal.currentSessionKey; - final currentTarget = assistantExecutionTargetForSession(currentSessionKey); - var connectionState = currentAssistantConnectionState; + final sessionKey = normalizedAssistantSessionKeyInternal( + sessionsControllerInternal.currentSessionKey, + ); + final currentTarget = assistantExecutionTargetForSession(sessionKey); + var connectionState = assistantConnectionStateForSession(sessionKey); if (!connectionState.connected && isBridgeAcpRuntimeConfiguredInternal() && bridgeCapabilityRefreshNeededForAssistantTargetInternal( @@ -241,7 +243,7 @@ extension AppControllerDesktopThreadActions on AppController { )) { try { await refreshAcpCapabilitiesInternal(forceRefresh: true); - connectionState = currentAssistantConnectionState; + connectionState = assistantConnectionStateForSession(sessionKey); } catch (_) { // Fallback to existing connection state if refresh fails. } @@ -249,7 +251,7 @@ extension AppControllerDesktopThreadActions on AppController { if (!connectionState.connected) { final error = StateError(connectionState.detailLabel); appendAssistantThreadMessageInternal( - currentSessionKey, + sessionKey, assistantErrorMessageInternal(error.message), ); await flushAssistantThreadPersistenceInternal(); @@ -258,17 +260,14 @@ extension AppControllerDesktopThreadActions on AppController { throw error; } await ensureDesktopTaskThreadBindingInternal( - currentSessionKey, + sessionKey, executionTarget: currentTarget, ); final workingDirectory = - assistantWorkingDirectoryForSessionInternal( - currentSessionKey, - )?.trim() ?? - ''; + assistantWorkingDirectoryForSessionInternal(sessionKey)?.trim() ?? ''; final remoteWorkingDirectoryHint = assistantRemoteWorkingDirectoryHintForSessionInternal( - currentSessionKey, + sessionKey, )?.trim() ?? ''; if (workingDirectory.isEmpty) { @@ -279,7 +278,7 @@ extension AppControllerDesktopThreadActions on AppController { ), ); appendAssistantThreadMessageInternal( - currentSessionKey, + sessionKey, assistantErrorMessageInternal(error.message), ); await flushAssistantThreadPersistenceInternal(); @@ -293,11 +292,8 @@ extension AppControllerDesktopThreadActions on AppController { // Keep the local guard focused on the post-refresh catalog state. } if (providerCatalogForExecutionTarget(currentTarget).isEmpty) { - final normalizedSessionKey = normalizedAssistantSessionKeyInternal( - currentSessionKey, - ); upsertTaskThreadInternal( - normalizedSessionKey, + sessionKey, selectedProvider: SingleAgentProvider.unspecified, selectedProviderSource: ThreadSelectionSource.inherited, latestResolvedProviderId: '', @@ -315,7 +311,7 @@ extension AppControllerDesktopThreadActions on AppController { ), ); appendAssistantThreadMessageInternal( - normalizedSessionKey, + sessionKey, assistantErrorMessageInternal(error.message), ); await flushAssistantThreadPersistenceInternal(); @@ -324,41 +320,158 @@ extension AppControllerDesktopThreadActions on AppController { throw error; } } - await enqueueThreadTurnInternal( - normalizedAssistantSessionKeyInternal(currentSessionKey), - () async { - final sessionKey = normalizedAssistantSessionKeyInternal( - currentSessionKey, + await enqueueThreadTurnInternal(sessionKey, () async { + final resumeSession = shouldResumeGatewaySessionForNextSendInternal( + sessionKey, + ); + final lifecycleStatus = taskThreadForSessionInternal( + sessionKey, + )?.lifecycleState.status.trim().toLowerCase(); + final lastResultCode = taskThreadForSessionInternal( + sessionKey, + )?.lifecycleState.lastResultCode?.trim().toLowerCase(); + final continuableTransportResult = + lastResultCode == 'acp_http_connection_closed' || + lastResultCode == + gatewayAcpHttpHandshakeInterruptedCode.toLowerCase(); + final runStatus = + resumeSession && + (lifecycleStatus == 'interrupted' || continuableTransportResult) + ? 'continuing' + : resumeSession && lastResultCode == 'error' + ? 'retrying' + : 'running'; + final userText = message.trim().isEmpty + ? 'See attached.' + : message.trim(); + appendLocalSessionMessageInternal( + sessionKey, + GatewayChatMessage( + id: nextLocalMessageIdInternal(), + role: 'user', + text: userText, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + persistInThreadContext: true, + ); + aiGatewayPendingSessionKeysInternal.add(sessionKey); + upsertTaskThreadInternal( + sessionKey, + lifecycleStatus: runStatus, + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: runStatus, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + recomputeTasksInternal(); + notifyIfActiveInternal(); + try { + final dispatch = await codeAgentNodeOrchestratorInternal + .buildGatewayDispatch( + buildCodeAgentNodeStateInternal(executionTarget: currentTarget), + ); + upsertTaskThreadInternal( + sessionKey, + lifecycleStatus: runStatus, + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: runStatus, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); - final resumeSession = shouldResumeGatewaySessionForNextSendInternal( - sessionKey, + final result = await goTaskServiceClientInternal.executeTask( + GoTaskServiceRequest( + sessionId: sessionKey, + threadId: sessionKey, + target: currentTarget, + provider: assistantProviderForSession(sessionKey), + prompt: message, + workingDirectory: workingDirectory, + remoteWorkingDirectoryHint: remoteWorkingDirectoryHint, + model: assistantModelForSession(sessionKey), + thinking: thinking, + selectedSkills: selectedSkillLabels, + inlineAttachments: attachments, + localAttachments: localAttachments, + agentId: dispatch.agentId ?? '', + metadata: dispatch.metadata, + routing: buildExternalAcpRoutingForSessionInternal(sessionKey), + routingHint: 'gateway', + resumeSession: resumeSession, + ), + onUpdate: (update) { + if (update.isDelta) { + appendAiGatewayStreamingTextInternal(sessionKey, update.text); + notifyIfActiveInternal(); + } + }, ); - final lifecycleStatus = taskThreadForSessionInternal( + if (!aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { + clearAiGatewayStreamingTextInternal(sessionKey); + return; + } + clearAiGatewayStreamingTextInternal(sessionKey); + upsertTaskThreadInternal( sessionKey, - )?.lifecycleState.status.trim().toLowerCase(); - final lastResultCode = taskThreadForSessionInternal( - sessionKey, - )?.lifecycleState.lastResultCode?.trim().toLowerCase(); - final continuableTransportResult = - lastResultCode == 'acp_http_connection_closed' || - lastResultCode == - gatewayAcpHttpHandshakeInterruptedCode.toLowerCase(); - final runStatus = - resumeSession && - (lifecycleStatus == 'interrupted' || continuableTransportResult) - ? 'continuing' - : resumeSession && lastResultCode == 'error' - ? 'retrying' - : 'running'; - final userText = message.trim().isEmpty - ? 'See attached.' - : message.trim(); + gatewayEntryState: goTaskServiceGatewayEntryState( + requestedTarget: currentTarget, + result: result, + ), + latestResolvedRuntimeModel: result.resolvedModel.trim(), + lastRemoteWorkingDirectory: + result.remoteWorkingDirectory.trim().isNotEmpty + ? result.remoteWorkingDirectory.trim() + : null, + lastRemoteWorkspaceRefKind: result.remoteWorkspaceRefKind, + lifecycleStatus: 'ready', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: result.success ? 'success' : 'error', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + if (isOpenClawNoExportedArtifactsGuardResultInternal(result)) { + await persistGoTaskArtifactsForSessionInternal(sessionKey, result); + return; + } + if (!result.success) { + appendLocalSessionMessageInternal( + sessionKey, + assistantErrorMessageInternal( + result.errorMessage.trim().isEmpty + ? appText( + 'GoTaskService 执行失败。', + 'GoTaskService execution failed.', + ) + : gatewayExecutionErrorLabelInternal( + result.errorMessage, + target: currentTarget, + ), + ), + persistInThreadContext: true, + ); + return; + } + final assistantText = result.message.trim(); + if (assistantText.isEmpty) { + appendLocalSessionMessageInternal( + sessionKey, + assistantErrorMessageInternal( + appText( + 'GoTaskService 没有返回可显示的输出。', + 'GoTaskService returned no displayable output.', + ), + ), + persistInThreadContext: true, + ); + return; + } appendLocalSessionMessageInternal( sessionKey, GatewayChatMessage( id: nextLocalMessageIdInternal(), - role: 'user', - text: userText, + role: 'assistant', + text: assistantText, timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), toolCallId: null, toolName: null, @@ -368,175 +481,54 @@ extension AppControllerDesktopThreadActions on AppController { ), persistInThreadContext: true, ); - aiGatewayPendingSessionKeysInternal.add(sessionKey); - upsertTaskThreadInternal( - sessionKey, - lifecycleStatus: runStatus, - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: runStatus, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); recomputeTasksInternal(); notifyIfActiveInternal(); - try { - final dispatch = await codeAgentNodeOrchestratorInternal - .buildGatewayDispatch(buildCodeAgentNodeStateInternal()); - upsertTaskThreadInternal( - sessionKey, - lifecycleStatus: runStatus, - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: runStatus, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - final result = await goTaskServiceClientInternal.executeTask( - GoTaskServiceRequest( - sessionId: sessionKey, - threadId: sessionKey, - target: currentTarget, - provider: assistantProviderForSession(sessionKey), - prompt: message, - workingDirectory: workingDirectory, - remoteWorkingDirectoryHint: remoteWorkingDirectoryHint, - model: assistantModelForSession(sessionKey), - thinking: thinking, - selectedSkills: selectedSkillLabels, - inlineAttachments: attachments, - localAttachments: localAttachments, - agentId: dispatch.agentId ?? '', - metadata: dispatch.metadata, - routing: buildExternalAcpRoutingForSessionInternal(sessionKey), - routingHint: 'gateway', - resumeSession: resumeSession, - ), - onUpdate: (update) { - if (update.isDelta) { - appendAiGatewayStreamingTextInternal(sessionKey, update.text); - notifyIfActiveInternal(); - } - }, - ); - if (!aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { - clearAiGatewayStreamingTextInternal(sessionKey); - return; - } + await persistGoTaskArtifactsForSessionInternal(sessionKey, result); + } catch (error) { + if (!aiGatewayPendingSessionKeysInternal.contains(sessionKey) && + taskThreadForSessionInternal( + sessionKey, + )?.lifecycleState.lastResultCode == + 'aborted') { clearAiGatewayStreamingTextInternal(sessionKey); - upsertTaskThreadInternal( - sessionKey, - gatewayEntryState: goTaskServiceGatewayEntryState( - requestedTarget: currentTarget, - result: result, - ), - latestResolvedRuntimeModel: result.resolvedModel.trim(), - lastRemoteWorkingDirectory: - result.remoteWorkingDirectory.trim().isNotEmpty - ? result.remoteWorkingDirectory.trim() - : null, - lastRemoteWorkspaceRefKind: result.remoteWorkspaceRefKind, - lifecycleStatus: 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: result.success ? 'success' : 'error', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - if (isOpenClawNoExportedArtifactsGuardResultInternal(result)) { - await persistGoTaskArtifactsForSessionInternal(sessionKey, result); - return; - } - if (!result.success) { - appendLocalSessionMessageInternal( - sessionKey, - assistantErrorMessageInternal( - result.errorMessage.trim().isEmpty - ? appText( - 'GoTaskService 执行失败。', - 'GoTaskService execution failed.', - ) - : gatewayExecutionErrorLabelInternal( - result.errorMessage, - target: currentTarget, - ), - ), - persistInThreadContext: true, - ); - return; - } - final assistantText = result.message.trim(); - if (assistantText.isEmpty) { - appendLocalSessionMessageInternal( - sessionKey, - assistantErrorMessageInternal( - appText( - 'GoTaskService 没有返回可显示的输出。', - 'GoTaskService returned no displayable output.', - ), - ), - persistInThreadContext: true, - ); - 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, - ); - recomputeTasksInternal(); - notifyIfActiveInternal(); - await persistGoTaskArtifactsForSessionInternal(sessionKey, result); - } catch (error) { - if (!aiGatewayPendingSessionKeysInternal.contains(sessionKey) && - taskThreadForSessionInternal( - sessionKey, - )?.lifecycleState.lastResultCode == - 'aborted') { - clearAiGatewayStreamingTextInternal(sessionKey); - return; - } - clearAiGatewayStreamingTextInternal(sessionKey); - final recoverableTransportCode = - recoverableAcpHttpTransportCodeInternal(error); - final unconfirmedConnectCode = unconfirmedAcpHttpConnectCodeInternal( - error, - ); - final recoverableTransportInterrupted = - recoverableTransportCode != null; - final visibleResultCode = - unconfirmedConnectCode ?? recoverableTransportCode; - upsertTaskThreadInternal( - sessionKey, - lifecycleStatus: recoverableTransportInterrupted - ? 'interrupted' - : 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: visibleResultCode ?? 'error', - lastArtifactSyncStatus: recoverableTransportInterrupted - ? 'interrupted' - : null, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - appendLocalSessionMessageInternal( - sessionKey, - assistantErrorMessageInternal( - gatewayExecutionErrorLabelInternal(error, target: currentTarget), - ), - persistInThreadContext: true, - ); - } finally { - aiGatewayPendingSessionKeysInternal.remove(sessionKey); - clearAiGatewayStreamingTextInternal(sessionKey); - recomputeTasksInternal(); - notifyIfActiveInternal(); + return; } - }, - ); + clearAiGatewayStreamingTextInternal(sessionKey); + final recoverableTransportCode = + recoverableAcpHttpTransportCodeInternal(error); + final unconfirmedConnectCode = unconfirmedAcpHttpConnectCodeInternal( + error, + ); + final recoverableTransportInterrupted = + recoverableTransportCode != null; + final visibleResultCode = + unconfirmedConnectCode ?? recoverableTransportCode; + upsertTaskThreadInternal( + sessionKey, + lifecycleStatus: recoverableTransportInterrupted + ? 'interrupted' + : 'ready', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: visibleResultCode ?? 'error', + lastArtifactSyncStatus: recoverableTransportInterrupted + ? 'interrupted' + : null, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + appendLocalSessionMessageInternal( + sessionKey, + assistantErrorMessageInternal( + gatewayExecutionErrorLabelInternal(error, target: currentTarget), + ), + persistInThreadContext: true, + ); + } finally { + aiGatewayPendingSessionKeysInternal.remove(sessionKey); + clearAiGatewayStreamingTextInternal(sessionKey); + recomputeTasksInternal(); + notifyIfActiveInternal(); + } + }); recomputeTasksInternal(); } diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index b76ce164..839a77e2 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -1153,6 +1153,265 @@ void main() { ); }); + test( + 'sendChatMessage keeps same-prompt draft task artifacts isolated', + () async { + final localHome = await Directory.systemTemp.createTemp( + 'xworkmate-same-prompt-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; + + const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容'; + final uniqueSuffix = DateTime.now().microsecondsSinceEpoch.toString(); + final sessionA = 'draft-task-a-$uniqueSuffix'; + final sessionB = 'draft-task-b-$uniqueSuffix'; + addTearDown(() async { + for (final sessionKey in [sessionA, sessionB]) { + final workspace = controller.assistantWorkspacePathForSession( + sessionKey, + ); + if (workspace.trim().isEmpty) { + continue; + } + final directory = Directory(workspace); + if (await directory.exists()) { + await directory.delete(recursive: true); + } + } + }); + + await controller.switchSession(sessionA); + final taskAFuture = controller.sendChatMessage(prompt); + await fakeGoTaskService.waitForRequestCount(1); + + await controller.switchSession(sessionB); + final taskBFuture = controller.sendChatMessage(prompt); + await fakeGoTaskService.waitForRequestCount(2); + + final taskARequest = fakeGoTaskService.requests[0]; + final taskBRequest = fakeGoTaskService.requests[1]; + expect(taskARequest.sessionId, sessionA); + expect(taskBRequest.sessionId, sessionB); + expect(taskARequest.prompt, taskBRequest.prompt); + expect(taskARequest.resumeSession, isFalse); + expect(taskBRequest.resumeSession, isFalse); + expect(taskARequest.workingDirectory, endsWith('/$sessionA')); + expect(taskBRequest.workingDirectory, endsWith('/$sessionB')); + expect( + taskARequest.workingDirectory, + isNot(taskBRequest.workingDirectory), + ); + expect( + taskARequest.remoteWorkingDirectoryHint, + isNot(taskBRequest.remoteWorkingDirectoryHint), + ); + expect( + taskARequest.remoteWorkingDirectoryHint, + endsWith('/threads/$sessionA'), + ); + expect( + taskBRequest.remoteWorkingDirectoryHint, + endsWith('/threads/$sessionB'), + ); + + fakeGoTaskService.complete( + sessionA, + GoTaskServiceResult( + success: true, + message: 'result A', + turnId: 'turn-a', + raw: { + 'remoteWorkingDirectory': + '/home/ubuntu/.openclaw/workspace/tasks/$sessionA/turn-a', + 'artifacts': >[ + { + 'relativePath': 'same-prompt-a.md', + 'content': 'artifact A', + 'contentType': 'text/markdown', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await taskAFuture; + + fakeGoTaskService.complete( + sessionB, + GoTaskServiceResult( + success: true, + message: 'result B', + turnId: 'turn-b', + raw: { + 'remoteWorkingDirectory': + '/home/ubuntu/.openclaw/workspace/tasks/$sessionB/turn-b', + 'artifacts': >[ + { + 'relativePath': 'same-prompt-b.md', + 'content': 'artifact B', + 'contentType': 'text/markdown', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await taskBFuture; + + final taskAWorkspace = controller.assistantWorkspacePathForSession( + sessionA, + ); + final taskBWorkspace = controller.assistantWorkspacePathForSession( + sessionB, + ); + expect( + await File('$taskAWorkspace/same-prompt-a.md').readAsString(), + 'artifact A', + ); + expect( + await File('$taskBWorkspace/same-prompt-b.md').readAsString(), + 'artifact B', + ); + + final taskAThread = controller.requireTaskThreadForSessionInternal( + sessionA, + ); + final taskBThread = controller.requireTaskThreadForSessionInternal( + sessionB, + ); + expect(taskAThread.lastArtifactSyncStatus, 'synced'); + expect(taskBThread.lastArtifactSyncStatus, 'synced'); + expect(taskAThread.lastTaskArtifactRelativePaths, [ + 'same-prompt-a.md', + ]); + expect(taskBThread.lastTaskArtifactRelativePaths, [ + 'same-prompt-b.md', + ]); + expect( + taskAThread.lastRemoteWorkingDirectory, + '/home/ubuntu/.openclaw/workspace/tasks/$sessionA/turn-a', + ); + expect( + taskBThread.lastRemoteWorkingDirectory, + '/home/ubuntu/.openclaw/workspace/tasks/$sessionB/turn-b', + ); + + final taskBSnapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: sessionB, + ); + expect( + taskBSnapshot.fileEntries.map((entry) => entry.relativePath), + ['same-prompt-b.md'], + ); + }, + ); + + test( + 'sendChatMessage clears same-prompt draft task artifacts when no files return', + () async { + final localHome = await Directory.systemTemp.createTemp( + 'xworkmate-same-prompt-empty-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; + + const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容'; + final uniqueSuffix = DateTime.now().microsecondsSinceEpoch.toString(); + final sessionA = 'draft-task-a-empty-$uniqueSuffix'; + final sessionB = 'draft-task-b-empty-$uniqueSuffix'; + addTearDown(() async { + for (final sessionKey in [sessionA, sessionB]) { + final workspace = controller.assistantWorkspacePathForSession( + sessionKey, + ); + if (workspace.trim().isEmpty) { + continue; + } + final directory = Directory(workspace); + if (await directory.exists()) { + await directory.delete(recursive: true); + } + } + }); + + await controller.switchSession(sessionA); + final taskAFuture = controller.sendChatMessage(prompt); + await fakeGoTaskService.waitForRequestCount(1); + fakeGoTaskService.complete( + sessionA, + const GoTaskServiceResult( + success: true, + message: 'result A', + turnId: 'turn-a', + raw: { + 'artifacts': >[ + { + 'relativePath': 'same-prompt-a.md', + 'content': 'artifact A', + 'contentType': 'text/markdown', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await taskAFuture; + + await controller.switchSession(sessionB); + final taskBFuture = controller.sendChatMessage(prompt); + await fakeGoTaskService.waitForRequestCount(2); + fakeGoTaskService.complete( + sessionB, + const GoTaskServiceResult( + success: true, + message: 'result B', + turnId: 'turn-b', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await taskBFuture; + + final taskBThread = controller.requireTaskThreadForSessionInternal( + sessionB, + ); + expect(taskBThread.lastArtifactSyncStatus, 'no-artifacts'); + expect(taskBThread.lastTaskArtifactRelativePaths, isEmpty); + + final taskBSnapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: sessionB, + ); + expect(taskBSnapshot.fileEntries, isEmpty); + expect( + taskBSnapshot.filesMessage, + 'No files found in the recorded working directory.', + ); + }, + ); + test('abortRun cancels only the current pending session', () async { final fakeGoTaskService = _BlockingGoTaskServiceClient(); final controller = _connectedController(fakeGoTaskService);