// ignore_for_file: unused_import, unnecessary_import import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'app_metadata.dart'; import 'app_capabilities.dart'; import 'app_store_policy.dart'; import 'ui_feature_manifest.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; import '../runtime/device_identity_store.dart'; import '../runtime/runtime_bootstrap.dart'; import '../runtime/desktop_platform_service.dart'; import '../runtime/gateway_runtime.dart'; import '../runtime/runtime_controllers.dart'; import '../runtime/runtime_models.dart'; import '../runtime/secure_config_store.dart'; import '../runtime/embedded_agent_launch_policy.dart'; import '../runtime/runtime_coordinator.dart'; import '../runtime/gateway_acp_client.dart'; import '../runtime/codex_runtime.dart'; import '../runtime/codex_config_bridge.dart'; import '../runtime/code_agent_node_orchestrator.dart'; import '../runtime/assistant_artifacts.dart'; import '../runtime/desktop_thread_artifact_service.dart'; import '../runtime/go_task_service_client.dart'; import '../runtime/mode_switcher.dart'; import '../runtime/agent_registry.dart'; import '../runtime/platform_environment.dart'; import 'app_controller_desktop_core.dart'; import 'app_controller_desktop_navigation.dart'; import 'app_controller_desktop_gateway.dart'; import 'app_controller_desktop_settings.dart'; import 'app_controller_desktop_thread_binding.dart'; import 'app_controller_desktop_thread_actions.dart'; import 'app_controller_desktop_workspace_execution.dart'; import 'app_controller_desktop_settings_runtime.dart'; import 'app_controller_desktop_thread_storage.dart'; import 'app_controller_desktop_skill_permissions.dart'; import 'app_controller_desktop_runtime_helpers.dart'; import 'app_controller_desktop_thread_sessions_collaboration_impl.dart'; // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member final RegExp _runtimeSessionKeyPatternInternal = RegExp( r'^session-\d+$', caseSensitive: false, ); AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({ required AssistantExecutionTarget target, required bool bridgeReady, required String bridgeLabel, required AccountSyncState? accountSyncState, required bool accountSignedIn, required bool bridgeConfigured, bool bridgeDiscoveryAttempted = false, String bridgeDiscoveryError = '', bool providerCatalogEmpty = false, }) { if (bridgeReady) { return AssistantThreadConnectionState( executionTarget: target, status: RuntimeConnectionStatus.connected, primaryLabel: RuntimeConnectionStatus.connected.label, detailLabel: bridgeLabel, ready: true, gatewayTokenMissing: false, lastError: null, ); } if (!accountSignedIn && !bridgeConfigured) { return AssistantThreadConnectionState( executionTarget: target, status: RuntimeConnectionStatus.offline, primaryLabel: appText('已退出登录', 'Signed out'), detailLabel: appText('请先登录 svc.plus', 'Please sign in to svc.plus first'), ready: false, gatewayTokenMissing: false, lastError: null, ); } final syncState = accountSyncState?.syncState.trim().toLowerCase() ?? ''; final syncMessage = accountSyncState?.syncMessage.trim() ?? ''; final tokenMissing = syncMessage == 'Bridge authorization is unavailable'; final endpointMissing = syncMessage == 'Bridge endpoint is unavailable'; final blocked = syncState == 'blocked'; final failed = blocked && !tokenMissing && !endpointMissing; // SyncBlocked logic if (accountSignedIn && (tokenMissing || failed || blocked)) { final status = RuntimeConnectionStatus.error; final primaryLabel = tokenMissing ? appText('缺少令牌', 'Missing Token') : failed ? appText('连接失败', 'Connection Failed') : status.label; final detailLabel = tokenMissing ? appText( 'xworkmate-bridge 授权不可用', 'xworkmate-bridge authorization unavailable', ) : failed ? appText('xworkmate-bridge 连接失败', 'xworkmate-bridge connection failed') : appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected'); return AssistantThreadConnectionState( executionTarget: target, status: status, primaryLabel: primaryLabel, detailLabel: detailLabel, ready: false, gatewayTokenMissing: tokenMissing, lastError: failed ? syncMessage : null, ); } final discoveryError = bridgeDiscoveryError.trim(); if (bridgeConfigured && bridgeDiscoveryAttempted) { final status = RuntimeConnectionStatus.error; final detailLabel = discoveryError.isNotEmpty ? discoveryError : providerCatalogEmpty ? appText( 'Gateway ACP 未报告可用的 provider', 'Gateway ACP did not report a usable provider', ) : appText( 'xworkmate-bridge 连接失败', 'xworkmate-bridge connection failed', ); return AssistantThreadConnectionState( executionTarget: target, status: status, primaryLabel: appText('连接失败', 'Connection Failed'), detailLabel: detailLabel, ready: false, gatewayTokenMissing: false, lastError: detailLabel, ); } // BridgeDiscovering logic (Signed in, not blocked, but not ready yet) if (bridgeConfigured) { return AssistantThreadConnectionState( executionTarget: target, status: RuntimeConnectionStatus.offline, primaryLabel: appText('正在发现', 'Discovering'), detailLabel: appText( '正在加载 Bridge 能力...', 'Loading Bridge capabilities...', ), ready: false, gatewayTokenMissing: false, lastError: null, ); } // Default Offline/Unconnected return AssistantThreadConnectionState( executionTarget: target, status: RuntimeConnectionStatus.offline, primaryLabel: RuntimeConnectionStatus.offline.label, detailLabel: appText( 'xworkmate-bridge 未连接', 'xworkmate-bridge is not connected', ), ready: false, gatewayTokenMissing: false, lastError: null, ); } bool bridgeCapabilityReadyForExecutionTargetInternal({ required AssistantExecutionTarget target, required bool bridgeConfigured, required List providers, required List availableTargets, }) { if (!bridgeConfigured || providers.isEmpty) { return false; } return availableTargets.isEmpty || availableTargets.contains(target); } extension AppControllerDesktopThreadSessions on AppController { bool isRuntimeOwnedAssistantSessionKeyInternal(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ).toLowerCase(); if (normalizedSessionKey.isEmpty) { return true; } if (normalizedSessionKey == 'main') { return true; } return _runtimeSessionKeyPatternInternal.hasMatch(normalizedSessionKey); } bool isAppOwnedAssistantSessionKeyInternal(String sessionKey) { return !isRuntimeOwnedAssistantSessionKeyInternal(sessionKey); } AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordsInternal( TaskThread? record, ) { return resolveAssistantExecutionTargetFromRecordForTest( record, defaultExecutionTarget: pickDraftThreadExecutionTargetInternal( currentTarget: sanitizePersistedExecutionTargetInternal( settings.assistantExecutionTarget, ), visibleTargets: visibleAssistantExecutionTargets( AssistantExecutionTarget.values, ), ), ); } TaskThread? taskThreadForSessionInternal(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); if (!isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey)) { return null; } return taskThreadRepositoryInternal.taskThreadForSession( normalizedSessionKey, ); } TaskThread requireTaskThreadForSessionInternal(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); return taskThreadRepositoryInternal.requireTaskThreadForSession( normalizedSessionKey, ); } bool hasAssistantTaskStateInternal(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); if (!isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey)) { return false; } return taskThreadRepositoryInternal.containsKey(normalizedSessionKey) || assistantThreadMessagesInternal.containsKey(normalizedSessionKey) || localSessionMessagesInternal.containsKey(normalizedSessionKey); } String createAssistantDraftSessionKeyInternal() { final selectedAgentId = agentsControllerInternal.selectedAgentId.trim(); for (var attempt = 0; attempt < 32; attempt += 1) { assistantDraftSessionCounterInternal += 1; final stamp = DateTime.now().microsecondsSinceEpoch; final suffix = '$stamp-$assistantDraftSessionCounterInternal'; final candidate = selectedAgentId.isEmpty ? 'draft:$suffix' : 'draft:$selectedAgentId:$suffix'; if (!hasAssistantTaskStateInternal(candidate)) { return candidate; } } throw StateError('Unable to allocate a unique draft task session key.'); } List assistantSelectedSkillKeysForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); final selected = assistantThreadRecordsInternal[normalizedSessionKey] ?.selectedSkillKeys ?? const []; final availableKeys = skills.map((item) => item.skillKey).toSet(); return selected.where(availableKeys.contains).toList(growable: false); } String assistantModelForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); final recordModel = assistantThreadRecordsInternal[normalizedSessionKey]?.assistantModelId .trim() ?? ''; final availableChoices = assistantModelChoicesForSessionInternal( normalizedSessionKey, ); if (recordModel.isNotEmpty && (availableChoices.isEmpty || availableChoices.contains(recordModel))) { return recordModel; } return resolvedAssistantModelForTargetInternal( AssistantExecutionTarget.gateway, ); } String assistantDisplayModelForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); final availableChoices = assistantModelChoicesForSessionInternal( normalizedSessionKey, ); if (availableChoices.isEmpty) { return ''; } final thread = taskThreadForSessionInternal(normalizedSessionKey); final latestResolvedModel = thread?.latestResolvedRuntimeModel.trim() ?? ''; if (availableChoices.contains(latestResolvedModel)) { return latestResolvedModel; } final selectedModel = thread?.assistantModelId.trim() ?? ''; if (availableChoices.contains(selectedModel)) { return selectedModel; } final target = assistantExecutionTargetForSession(normalizedSessionKey); final defaultModel = resolvedAssistantModelForTargetInternal(target).trim(); if (availableChoices.contains(defaultModel)) { return defaultModel; } return availableChoices.length == 1 ? availableChoices.first : ''; } String assistantWorkspacePathForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); return taskThreadForSessionInternal( normalizedSessionKey, )?.workspaceBinding.workspacePath.trim() ?? ''; } WorkspaceRefKind assistantWorkspaceKindForSession(String sessionKey) { final record = taskThreadForSessionInternal( normalizedAssistantSessionKeyInternal(sessionKey), ); if (record == null) { return WorkspaceRefKind.remotePath; } return record.workspaceBinding.workspaceKind == WorkspaceKind.localFs ? WorkspaceRefKind.localPath : WorkspaceRefKind.remotePath; } String assistantWorkspaceDisplayPathForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); return taskThreadForSessionInternal( normalizedSessionKey, )?.workspaceBinding.displayPath.trim() ?? ''; } double? assistantArtifactSyncAtMsForSession(String sessionKey) { return taskThreadForSessionInternal( normalizedAssistantSessionKeyInternal(sessionKey), )?.lastArtifactSyncAtMs; } String assistantArtifactSyncStatusForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); final thread = taskThreadForSessionInternal(normalizedSessionKey); return thread?.lastArtifactSyncStatus?.trim() ?? ''; } Future loadAssistantArtifactSnapshot({ String? sessionKey, }) async { final resolvedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey ?? currentSessionKey, ); final thread = taskThreadForSessionInternal(resolvedSessionKey); final snapshot = await threadArtifactServiceInternal.loadSnapshot( workspacePath: assistantWorkspacePathForSession(resolvedSessionKey), workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey), artifactRelativePaths: thread?.lastTaskArtifactRelativePaths ?? const [], ); if (thread == null) { return snapshot; } final shouldRefreshRemote = _shouldRefreshRemoteArtifactSnapshotInternal( thread, ); if (snapshot.fileEntries.isNotEmpty && !shouldRefreshRemote) { return snapshot; } final synced = await syncRemoteTaskArtifactsForSessionInternal( resolvedSessionKey, ); if (!synced) { return snapshot; } final refreshedThread = taskThreadForSessionInternal(resolvedSessionKey); return threadArtifactServiceInternal.loadSnapshot( workspacePath: assistantWorkspacePathForSession(resolvedSessionKey), workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey), artifactRelativePaths: refreshedThread?.lastTaskArtifactRelativePaths ?? const [], ); } bool _shouldRefreshRemoteArtifactSnapshotInternal(TaskThread thread) { final syncStatus = thread.lastArtifactSyncStatus?.trim().toLowerCase(); if (syncStatus == 'partial' || syncStatus == 'syncing' || syncStatus == 'running' || syncStatus == 'queued') { return true; } final association = thread.openClawTaskAssociation; return association != null && !association.isTerminal; } Future syncRemoteTaskArtifactsForSessionInternal( String sessionKey, ) async { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); final thread = taskThreadForSessionInternal(normalizedSessionKey); if (thread == null || thread.workspaceBinding.workspaceKind != WorkspaceKind.localFs) { return false; } final association = thread.openClawTaskAssociation ?? _inferOpenClawTaskAssociationFromThreadInternal(thread); if (association == null) { return false; } try { final result = await goTaskServiceClientInternal.getTask( route: GoTaskServiceRoute.externalAcpSingle, target: assistantExecutionTargetFromExecutionMode( thread.executionBinding.executionMode, ), association: association, ); if (result.artifacts.isEmpty) { return false; } final status = result.status.trim(); final nextAssociation = result.openClawTaskAssociation ?? association.copyWith(status: status.isEmpty ? 'completed' : status); upsertTaskThreadInternal( normalizedSessionKey, openClawTaskAssociation: nextAssociation, lastRemoteWorkingDirectory: result.remoteWorkingDirectory.trim().isEmpty ? thread.lastRemoteWorkingDirectory : result.remoteWorkingDirectory.trim(), lastRemoteWorkspaceRefKind: result.remoteWorkspaceRefKind ?? thread.lastRemoteWorkspaceRefKind, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); await persistGoTaskArtifactsForSessionInternal( normalizedSessionKey, result, ); final refreshed = taskThreadForSessionInternal(normalizedSessionKey); return refreshed?.lastTaskArtifactRelativePaths.isNotEmpty == true; } catch (error) { debugPrint( 'Remote artifact sync failed for $normalizedSessionKey: $error', ); return false; } } Future loadAssistantArtifactPreview( AssistantArtifactEntry entry, { String? sessionKey, }) { final resolvedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey ?? currentSessionKey, ); final thread = taskThreadForSessionInternal(resolvedSessionKey); return threadArtifactServiceInternal.loadPreview( entry: entry, workspacePath: assistantWorkspacePathForSession(resolvedSessionKey), workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey), artifactRelativePaths: thread?.lastTaskArtifactRelativePaths ?? const [], ); } String get assistantConversationOwnerLabel { return activeAgentName; } String get resolvedAssistantModel => resolvedAssistantModelForTargetInternal(currentAssistantExecutionTarget); AssistantThreadConnectionState get currentAssistantConnectionState => assistantConnectionStateForSession(currentSessionKey); AssistantThreadConnectionState assistantConnectionStateForSession( String sessionKey, ) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); final target = assistantExecutionTargetForSession(normalizedSessionKey); final providers = providerCatalogForExecutionTarget(target); final availableTargets = bridgeAvailableExecutionTargets; final bridgeConfigured = isBridgeAcpRuntimeConfiguredInternal(); final bridgeReady = bridgeCapabilityReadyForExecutionTargetInternal( target: target, bridgeConfigured: bridgeConfigured, providers: providers, availableTargets: availableTargets, ); final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); final bridgeLabel = bridgeEndpoint?.host.trim().isNotEmpty == true ? bridgeEndpoint!.host.trim() : 'xworkmate-bridge'; return resolveGatewayThreadConnectionStateInternal( target: target, bridgeReady: bridgeReady, bridgeLabel: bridgeLabel, accountSyncState: settingsControllerInternal.accountSyncState, accountSignedIn: settingsControllerInternal.accountSignedIn, bridgeConfigured: bridgeConfigured, bridgeDiscoveryAttempted: bridgeCapabilitiesRefreshAttemptedInternal, bridgeDiscoveryError: bridgeCapabilitiesRefreshErrorInternal, providerCatalogEmpty: providers.isEmpty, ); } bool bridgeCapabilityReadyForAssistantTargetInternal( AssistantExecutionTarget target, ) { return bridgeCapabilityReadyForExecutionTargetInternal( target: target, bridgeConfigured: isBridgeAcpRuntimeConfiguredInternal(), providers: providerCatalogForExecutionTarget(target), availableTargets: bridgeAvailableExecutionTargets, ); } bool bridgeCapabilityRefreshNeededForAssistantTargetInternal( AssistantExecutionTarget target, ) { if (!isBridgeAcpRuntimeConfiguredInternal()) { return false; } return !bridgeCapabilityReadyForAssistantTargetInternal(target); } String get assistantConnectionStatusLabel => currentAssistantConnectionState.primaryLabel; String get assistantConnectionTargetLabel => currentAssistantConnectionState.detailLabel; Future loadAiGatewayApiKey() => loadAiGatewayApiKeyThreadSessionInternal(this); Future openOnlineWorkspace() => openOnlineWorkspaceThreadSessionInternal(this); List get aiGatewayModelChoices => aiGatewayModelChoicesThreadSessionInternal(this); List get connectedGatewayModelChoices => connectedGatewayModelChoicesThreadSessionInternal(this); List get assistantModelChoices => assistantModelChoicesThreadSessionInternal(this); List assistantModelChoicesForSessionInternal(String sessionKey) => assistantModelChoicesForSessionThreadSessionInternal(this, sessionKey); String get resolvedDefaultModel => resolvedDefaultModelThreadSessionInternal(this); bool get canQuickConnectGateway => canQuickConnectGatewayThreadSessionInternal(this); String joinConnectionPartsInternal(List parts) => joinConnectionPartsThreadSessionInternal(parts); String gatewayAddressLabelInternal(GatewayConnectionProfile profile) => gatewayAddressLabelThreadSessionInternal(profile); List get assistantNavigationDestinations => normalizeAssistantNavigationDestinations( appUiState.assistantNavigationDestinations, ).where(supportsAssistantFocusEntry).toList(growable: false); bool supportsAssistantFocusEntry(AssistantFocusEntry entry) { final destination = entry.destination; if (destination != null) { return capabilities.supportsDestination(destination); } return capabilities.supportsDestination(WorkspaceDestination.settings); } List get chatMessages { final sessionKey = normalizedAssistantSessionKeyInternal( sessionsControllerInternal.currentSessionKey, ); final chatSessionKey = normalizedAssistantSessionKeyInternal( chatControllerInternal.sessionKey, ); final items = matchesSessionKey(chatSessionKey, sessionKey) ? List.from(chatControllerInternal.messages) : []; final threadItems = assistantThreadMessagesInternal[sessionKey]; if (threadItems != null && threadItems.isNotEmpty) { items.addAll(threadItems); } final localItems = localSessionMessagesInternal[sessionKey]; if (localItems != null && localItems.isNotEmpty) { items.addAll(localItems); } final streaming = matchesSessionKey(chatSessionKey, sessionKey) ? chatControllerInternal.streamingAssistantText?.trim() ?? '' : ''; if (streaming.isNotEmpty) { items.add( GatewayChatMessage( id: 'streaming', role: 'assistant', text: streaming, timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), toolCallId: null, toolName: null, stopReason: null, pending: true, error: false, ), ); } return dedupeGatewayChatMessagesByIdThreadSessionInternal(items); } List dedupeGatewayChatMessagesByIdThreadSessionInternal( List messages, ) { final seenIds = {}; final deduped = []; for (final message in messages) { final id = message.id.trim(); if (id.isNotEmpty && !seenIds.add(id)) { continue; } deduped.add(message); } return deduped; } String normalizedAssistantSessionKeyInternal(String sessionKey) { final trimmed = sessionKey.trim(); return trimmed.isEmpty ? 'main' : trimmed; } AssistantExecutionTarget assistantExecutionTargetForSession( String sessionKey, ) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); final record = taskThreadForSessionInternal(normalizedSessionKey); return resolveAssistantExecutionTargetFromRecordsInternal(record); } AssistantMessageViewMode assistantMessageViewModeForSession( String sessionKey, ) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); return assistantThreadRecordsInternal[normalizedSessionKey] ?.messageViewMode ?? AssistantMessageViewMode.rendered; } WorkspaceRefKind defaultWorkspaceRefKindForTargetInternal( AssistantExecutionTarget target, ) => WorkspaceRefKind.remotePath; List assistantSessionsInternal() { final byKey = {}; for (final record in assistantThreadRecordsInternal.values) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( record.sessionKey, ); if (normalizedSessionKey.isEmpty || !isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey) || isAssistantTaskArchived(normalizedSessionKey) || record.archived) { continue; } byKey.putIfAbsent( normalizedSessionKey, () => assistantSessionSummaryForInternal( normalizedSessionKey, record: record, ), ); } final currentKey = normalizedAssistantSessionKeyInternal(currentSessionKey); if (isAppOwnedAssistantSessionKeyInternal(currentKey) && !isAssistantTaskArchived(currentKey) && !byKey.containsKey(currentKey)) { byKey[currentKey] = assistantSessionSummaryForInternal(currentKey); } return byKey.values.toList(growable: false); } List archivedAssistantSessionsInternal() { final items = []; for (final record in assistantThreadRecordsInternal.values) { final sessionKey = normalizedAssistantSessionKeyInternal( record.sessionKey, ); if (!isAppOwnedAssistantSessionKeyInternal(sessionKey) || !record.archived) { continue; } items.add(assistantSessionSummaryForInternal(sessionKey, record: record)); } items.sort((left, right) { return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0); }); return items; } } OpenClawTaskAssociation? _inferOpenClawTaskAssociationFromThreadInternal( TaskThread thread, ) { final remoteWorkspace = thread.lastRemoteWorkingDirectory?.trim() ?? ''; if (remoteWorkspace.isEmpty) { return null; } final normalized = remoteWorkspace.replaceAll('\\', '/'); final segments = normalized .split('/') .where((segment) => segment.trim().isNotEmpty) .toList(growable: false); final tasksIndex = segments.lastIndexOf('tasks'); if (tasksIndex < 0 || tasksIndex + 2 >= segments.length) { return null; } final taskDir = segments[tasksIndex + 1].trim(); final runId = segments[tasksIndex + 2].trim(); final openclawSessionKey = _openClawSessionKeyFromTaskDirInternal(taskDir); final appThreadKey = _appThreadKeyFromSessionKeyInternal(thread.threadId); if (runId.isEmpty || openclawSessionKey.isEmpty || appThreadKey.isEmpty) { return null; } return OpenClawTaskAssociation( sessionId: thread.threadId, threadId: thread.threadId, turnId: runId, runId: runId, artifactScope: remoteWorkspace, artifactDirectory: remoteWorkspace, gatewayProviderId: 'openclaw', startedAtMs: thread.lifecycleState.lastRunAtMs ?? 0, status: 'completed', appThreadKey: appThreadKey, openclawSessionKey: openclawSessionKey, ); } String _appThreadKeyFromSessionKeyInternal(String sessionKey) { final normalized = sessionKey.trim(); if (normalized.startsWith('draft:')) { return normalized; } if (normalized.startsWith('draft-')) { return 'draft:${normalized.substring('draft-'.length)}'; } return normalized; } String _openClawSessionKeyFromTaskDirInternal(String taskDir) { final normalized = taskDir.trim(); if (normalized.startsWith('agent_main_draft_')) { return 'agent:main:draft:${normalized.substring('agent_main_draft_'.length)}'; } if (normalized.startsWith('draft_')) { return 'draft:${normalized.substring('draft_'.length)}'; } return ''; } AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordForTest( TaskThread? record, { required AssistantExecutionTarget defaultExecutionTarget, }) { return record == null ? defaultExecutionTarget : assistantExecutionTargetFromExecutionMode( record.executionBinding.executionMode, ); }