Fix atomic assistant task execution state

This commit is contained in:
Haitao Pan 2026-05-12 21:23:58 +08:00
parent 2eb52a5bc3
commit 71b78dae66
9 changed files with 485 additions and 76 deletions

View File

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

View File

@ -124,14 +124,20 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
assistantThreadTurnQueuesInternal[normalizedThreadId] ??
Future<void>.value();
final completer = Completer<T>();
T? result;
Object? failure;
StackTrace? failureStackTrace;
var taskCompleted = false;
late final Future<void> 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;

View File

@ -181,12 +181,25 @@ extension AppControllerDesktopThreadActions on AppController {
}
Future<void> 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<String, dynamic>.unmodifiable(dispatch.metadata),
resumeSessionHint: resumeSessionHint,
),
);
return;
@ -377,6 +394,7 @@ extension AppControllerDesktopThreadActions on AppController {
routing: routing,
agentId: dispatch.agentId ?? '',
metadata: Map<String, dynamic>.unmodifiable(dispatch.metadata),
resumeSessionHint: resumeSessionHint,
),
);
recomputeTasksInternal();
@ -397,10 +415,11 @@ extension AppControllerDesktopThreadActions on AppController {
required ExternalCodeAgentAcpRoutingConfig routing,
required String agentId,
required Map<String, dynamic> 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 <String>[],
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 <String>[],
@ -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 <String>[],
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 <String>[],
updatedAtMs: startedAtMs,
);
recomputeTasksInternal();
notifyIfActiveInternal();
}
void clearGatewayTaskArtifactStateInternal(
String sessionKey, {
required double completedAtMs,
required String syncStatus,
}) {
upsertTaskThreadInternal(
sessionKey,
lastArtifactSyncAtMs: completedAtMs,
lastArtifactSyncStatus: syncStatus,
lastTaskArtifactRelativePaths: const <String>[],
updatedAtMs: completedAtMs,
);
}
Future<void> 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 <String>[],
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 <String>[],
@ -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 <String>[],
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 <String>[],
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
recomputeTasksInternal();

View File

@ -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<String> 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,
);

View File

@ -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<String, dynamic> metadata;
final bool resumeSessionHint;
final Completer<void> completer = Completer<void>();
bool cancelled = false;

View File

@ -82,7 +82,7 @@ class _AppShellState extends State<AppShell> {
AppController controller,
List<AssistantExecutionTarget> 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<AppShell> {
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

View File

@ -407,7 +407,8 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
}
Future<void> 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<AssistantFocusEntry> favorites,
) {

View File

@ -194,9 +194,9 @@ class GatewayChatController extends ChangeNotifier {
Future<void> loadSession(String sessionKey) async {
final next = sessionKey.trim().isEmpty ? 'main' : sessionKey.trim();
sessionKeyInternal = next;
messagesInternal = const <GatewayChatMessage>[];
streamingAssistantTextInternal = null;
if (!runtimeInternal.isConnected) {
messagesInternal = const <GatewayChatMessage>[];
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 <GatewayChatMessage>[];
errorInternal = error.toString();
} finally {
loadingInternal = false;

View File

@ -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 <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
initialGatewayProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.openclaw,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
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 <String, String>{},
);
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: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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: <String, dynamic>{'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: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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: <String, dynamic>{},
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 <String, dynamic>{},
resumeSessionHint: false,
);
controller.openClawGatewayQueuedTurnsInternal.add(turn);
controller.openClawGatewayQueuedTurnsBySessionInternal[sessionKey] =