Fix atomic assistant task execution state
This commit is contained in:
parent
2eb52a5bc3
commit
71b78dae66
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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] =
|
||||
|
||||
Loading…
Reference in New Issue
Block a user