fix: isolate same-prompt draft artifacts

This commit is contained in:
Haitao Pan 2026-05-09 18:07:57 +08:00
parent e288be2454
commit 3e188cc3df
4 changed files with 472 additions and 215 deletions

View File

@ -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,

View File

@ -603,8 +603,12 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
);
}
CodeAgentNodeState buildCodeAgentNodeStateInternal() =>
buildCodeAgentNodeStateRuntimeInternal(this);
CodeAgentNodeState buildCodeAgentNodeStateInternal({
AssistantExecutionTarget? executionTarget,
}) => buildCodeAgentNodeStateRuntimeInternal(
this,
executionTarget: executionTarget,
);
GatewayMode bridgeGatewayModeInternal() =>
bridgeGatewayModeRuntimeInternal(this);

View File

@ -231,9 +231,11 @@ extension AppControllerDesktopThreadActions on AppController {
const <CollaborationAttachment>[],
List<String> selectedSkillLabels = const <String>[],
}) 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<void>(
normalizedAssistantSessionKeyInternal(currentSessionKey),
() async {
final sessionKey = normalizedAssistantSessionKeyInternal(
currentSessionKey,
await enqueueThreadTurnInternal<void>(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();
}

View File

@ -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 <String>[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: <String, dynamic>{
'remoteWorkingDirectory':
'/home/ubuntu/.openclaw/workspace/tasks/$sessionA/turn-a',
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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: <String, dynamic>{
'remoteWorkingDirectory':
'/home/ubuntu/.openclaw/workspace/tasks/$sessionB/turn-b',
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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, <String>[
'same-prompt-a.md',
]);
expect(taskBThread.lastTaskArtifactRelativePaths, <String>[
'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),
<String>['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 <String>[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: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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: <String, dynamic>{},
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);