From 06bde08ce3298447f1d26047e3bcb3d89462ee80 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 13 May 2026 12:37:34 +0800 Subject: [PATCH] Remove runtime session task binding --- .../app_controller_desktop_navigation.dart | 8 +- ...ler_desktop_runtime_coordination_impl.dart | 8 +- lib/app/app_controller_desktop_settings.dart | 10 +- ...p_controller_desktop_settings_runtime.dart | 1 - ..._controller_desktop_skill_permissions.dart | 17 +- ...app_controller_desktop_thread_actions.dart | 42 ++- ...app_controller_desktop_thread_binding.dart | 6 + ...pp_controller_desktop_thread_sessions.dart | 45 +++- ...op_thread_sessions_collaboration_impl.dart | 14 +- ...app_controller_desktop_thread_storage.dart | 33 ++- lib/app/app_shell_desktop.dart | 8 +- .../runtime_controllers_derived_tasks.dart | 14 - lib/runtime/runtime_controllers_gateway.dart | 29 +- .../assistant_artifact_sidebar_test.dart | 2 +- .../assistant/assistant_lower_pane_test.dart | 39 +-- ...troller_thread_workspace_binding_test.dart | 138 ++++++---- .../assistant_connection_state_test.dart | 18 +- .../assistant_execution_target_test.dart | 252 ++++++++++++++---- .../runtime/assistant_model_display_test.dart | 12 +- 19 files changed, 435 insertions(+), 261 deletions(-) diff --git a/lib/app/app_controller_desktop_navigation.dart b/lib/app/app_controller_desktop_navigation.dart index 87199f4d..d939a386 100644 --- a/lib/app/app_controller_desktop_navigation.dart +++ b/lib/app/app_controller_desktop_navigation.dart @@ -68,10 +68,6 @@ extension AppControllerDesktopNavigation on AppController { } void navigateHome() { - final mainSessionKey = - runtimeInternal.snapshot.mainSessionKey?.trim().isNotEmpty == true - ? runtimeInternal.snapshot.mainSessionKey!.trim() - : 'main'; final homeDestination = capabilities.supportsDestination(WorkspaceDestination.assistant) ? WorkspaceDestination.assistant @@ -90,8 +86,8 @@ extension AppControllerDesktopNavigation on AppController { if (destinationChanged || detailChanged || settingsDrillInChanged) { notifyListeners(); } - if (sessionsControllerInternal.currentSessionKey != mainSessionKey) { - unawaited(switchSession(mainSessionKey)); + if (!isAppOwnedAssistantSessionKeyInternal(currentSessionKey)) { + unawaited(ensureActiveAssistantThreadInternal()); } } diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index 9bbcdb73..e7172b9b 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -187,7 +187,7 @@ String? assistantRemoteWorkingDirectoryHintForSessionRuntimeInternal( ); final candidate = controller - .assistantThreadRecordsInternal[normalizedSessionKey] + .taskThreadForSessionInternal(normalizedSessionKey) ?.lastRemoteWorkingDirectory ?.trim() ?? ''; @@ -202,9 +202,7 @@ String? resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal( String sessionKey, { bool requireLocalExistence = true, }) { - final record = - controller.assistantThreadRecordsInternal[controller - .normalizedAssistantSessionKeyInternal(sessionKey)]; + final record = controller.taskThreadForSessionInternal(sessionKey); if (record?.workspaceKind != WorkspaceKind.localFs) { return null; } @@ -328,7 +326,7 @@ void clearCodexGatewayRegistrationRuntimeInternal(AppController controller) { void recomputeTasksRuntimeInternal(AppController controller) { controller.tasksControllerInternal.recompute( - sessions: controller.sessions, + sessions: controller.assistantSessions, cronJobs: controller.cronJobsControllerInternal.items, currentSessionKey: controller.sessionsControllerInternal.currentSessionKey, hasPendingRun: controller.hasAssistantPendingRun, diff --git a/lib/app/app_controller_desktop_settings.dart b/lib/app/app_controller_desktop_settings.dart index 59cec4ab..78febf1a 100644 --- a/lib/app/app_controller_desktop_settings.dart +++ b/lib/app/app_controller_desktop_settings.dart @@ -307,22 +307,18 @@ extension AppControllerDesktopSettings on AppController { openClawGatewayQueuedTurnsBySessionInternal.clear(); openClawGatewayActiveTasksInternal = 0; multiAgentRunPendingInternal = false; + final sessionKey = createAssistantDraftSessionKeyInternal(); initializeAssistantThreadContext( - 'main', + sessionKey, executionTarget: sanitizePersistedExecutionTargetInternal( currentSettings.assistantExecutionTarget, ), messageViewMode: AssistantMessageViewMode.rendered, ); await setCurrentAssistantSessionKeyInternal( - 'main', + sessionKey, persistSelection: false, ); - taskThreadRepositoryInternal.removeWhere( - (key, _) => key != 'main', - persist: false, - ); - assistantThreadMessagesInternal.removeWhere((key, _) => key != 'main'); await flushAssistantThreadPersistenceInternal(); await storeInternal.saveTaskThreads( taskThreadRepositoryInternal.snapshot(), diff --git a/lib/app/app_controller_desktop_settings_runtime.dart b/lib/app/app_controller_desktop_settings_runtime.dart index 9bc2da6e..4007c24c 100644 --- a/lib/app/app_controller_desktop_settings_runtime.dart +++ b/lib/app/app_controller_desktop_settings_runtime.dart @@ -540,7 +540,6 @@ extension AppControllerDesktopSettingsRuntime on AppController { '', ); sessionsControllerInternal.configure( - mainSessionKey: runtimeInternal.snapshot.mainSessionKey ?? 'main', selectedAgentId: agentsControllerInternal.selectedAgentId, defaultAgentId: '', ); diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index b9b96d6c..0c570940 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -247,6 +247,11 @@ extension AppControllerDesktopSkillPermissions on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); + if (!isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey)) { + throw StateError( + 'Runtime session key "$normalizedSessionKey" cannot be used as an app task.', + ); + } final existing = taskThreadForSessionInternal(normalizedSessionKey); final nextExecutionTarget = executionTarget ?? @@ -439,11 +444,17 @@ extension AppControllerDesktopSkillPermissions on AppController { String sessionKey, { bool persistSelection = true, }) async { - final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + var normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); - if (normalizedSessionKey.isEmpty) { - return; + if (!isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey)) { + normalizedSessionKey = createAssistantDraftSessionKeyInternal(); + initializeAssistantThreadContext( + normalizedSessionKey, + title: appText('新对话', 'New conversation'), + executionTarget: currentAssistantExecutionTarget, + messageViewMode: currentAssistantMessageViewMode, + ); } await sessionsControllerInternal.switchSession(normalizedSessionKey); if (persistSelection) { diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 7662f710..6a3a91c8 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -144,7 +144,6 @@ extension AppControllerDesktopThreadActions on AppController { Future refreshAgents() async { await agentsControllerInternal.refresh(); sessionsControllerInternal.configure( - mainSessionKey: runtimeInternal.snapshot.mainSessionKey ?? 'main', selectedAgentId: agentsControllerInternal.selectedAgentId, defaultAgentId: '', ); @@ -165,13 +164,13 @@ extension AppControllerDesktopThreadActions on AppController { refreshAfterSave: false, ); sessionsControllerInternal.configure( - mainSessionKey: runtimeInternal.snapshot.mainSessionKey ?? 'main', selectedAgentId: agentsControllerInternal.selectedAgentId, defaultAgentId: '', ); - await chatControllerInternal.loadSession( - sessionsControllerInternal.currentSessionKey, - ); + final sessionKey = normalizedAssistantSessionKeyInternal(currentSessionKey); + if (isAppOwnedAssistantSessionKeyInternal(sessionKey)) { + await chatControllerInternal.loadSession(sessionKey); + } await skillsControllerInternal.refresh( agentId: agentsControllerInternal.selectedAgentId.isEmpty ? null @@ -181,33 +180,26 @@ extension AppControllerDesktopThreadActions on AppController { } Future 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( + await ensureActiveAssistantThreadInternal(); + final selectedSessionKey = normalizedAssistantSessionKeyInternal( sessionsControllerInternal.currentSessionKey, ); + if (isAppOwnedAssistantSessionKeyInternal(selectedSessionKey)) { + await chatControllerInternal.loadSession(selectedSessionKey); + } recomputeTasksInternal(); } Future switchSession(String sessionKey) async { - final nextSessionKey = normalizedAssistantSessionKeyInternal(sessionKey); + var nextSessionKey = normalizedAssistantSessionKeyInternal(sessionKey); + if (!isAppOwnedAssistantSessionKeyInternal(nextSessionKey)) { + nextSessionKey = createAssistantDraftSessionKeyInternal(); + } final nextTarget = assistantExecutionTargetForSession(nextSessionKey); final nextViewMode = assistantMessageViewModeForSession(nextSessionKey); @@ -245,9 +237,15 @@ extension AppControllerDesktopThreadActions on AppController { const [], List selectedSkillLabels = const [], }) async { - final sessionKey = normalizedAssistantSessionKeyInternal( + var sessionKey = normalizedAssistantSessionKeyInternal( sessionsControllerInternal.currentSessionKey, ); + if (!isAppOwnedAssistantSessionKeyInternal(sessionKey)) { + await ensureActiveAssistantThreadInternal(); + sessionKey = normalizedAssistantSessionKeyInternal( + sessionsControllerInternal.currentSessionKey, + ); + } final currentTarget = assistantExecutionTargetForSession(sessionKey); final resumeSessionHint = shouldResumeGatewaySessionForNextSendInternal( sessionKey, diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index 1f3fcf51..7a2f5e30 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -95,6 +95,9 @@ extension AppControllerDesktopThreadBinding on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); + if (!isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey)) { + return ''; + } final homeDirectory = resolvedUserHomeDirectoryInternal.trim(); if (homeDirectory.isEmpty) { return ''; @@ -114,6 +117,9 @@ extension AppControllerDesktopThreadBinding on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); + if (!isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey)) { + return ''; + } return '\$HOME/.xworkmate/threads/${threadWorkspaceDirectoryNameInternal(normalizedSessionKey)}'; } diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 86d65605..6b9e70ef 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -48,6 +48,11 @@ 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, @@ -186,6 +191,26 @@ bool bridgeCapabilityReadyForExecutionTargetInternal({ } extension AppControllerDesktopThreadSessions on AppController { + bool isRuntimeOwnedAssistantSessionKeyInternal(String sessionKey) { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ).toLowerCase(); + if (normalizedSessionKey.isEmpty) { + return true; + } + if (normalizedSessionKey == 'main') { + return true; + } + if (normalizedSessionKey.startsWith('agent:')) { + return true; + } + return _runtimeSessionKeyPatternInternal.hasMatch(normalizedSessionKey); + } + + bool isAppOwnedAssistantSessionKeyInternal(String sessionKey) { + return !isRuntimeOwnedAssistantSessionKeyInternal(sessionKey); + } + AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordsInternal( TaskThread? record, ) { @@ -206,6 +231,9 @@ extension AppControllerDesktopThreadSessions on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); + if (!isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey)) { + return null; + } return taskThreadRepositoryInternal.taskThreadForSession( normalizedSessionKey, ); @@ -224,6 +252,9 @@ extension AppControllerDesktopThreadSessions on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); + if (!isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey)) { + return false; + } return taskThreadRepositoryInternal.containsKey(normalizedSessionKey) || assistantThreadMessagesInternal.containsKey(normalizedSessionKey) || localSessionMessagesInternal.containsKey(normalizedSessionKey); @@ -587,21 +618,12 @@ extension AppControllerDesktopThreadSessions on AppController { List assistantSessionsInternal() { final byKey = {}; - for (final session in sessionsControllerInternal.sessions) { - final normalizedSessionKey = normalizedAssistantSessionKeyInternal( - session.key, - ); - if (isAssistantTaskArchived(normalizedSessionKey)) { - continue; - } - byKey[normalizedSessionKey] = session; - } - for (final record in assistantThreadRecordsInternal.values) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( record.sessionKey, ); if (normalizedSessionKey.isEmpty || + !isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey) || isAssistantTaskArchived(normalizedSessionKey) || record.archived) { continue; @@ -616,7 +638,8 @@ extension AppControllerDesktopThreadSessions on AppController { } final currentKey = normalizedAssistantSessionKeyInternal(currentSessionKey); - if (!isAssistantTaskArchived(currentKey) && + if (isAppOwnedAssistantSessionKeyInternal(currentKey) && + !isAssistantTaskArchived(currentKey) && !byKey.containsKey(currentKey)) { byKey[currentKey] = assistantSessionSummaryForInternal(currentKey); } diff --git a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart index bb49413d..062094c7 100644 --- a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart +++ b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart @@ -102,9 +102,12 @@ Future runMultiAgentCollaborationThreadSessionInternal( required List attachments, required List selectedSkillLabels, }) async { - final sessionKey = controller.currentSessionKey.trim().isEmpty - ? 'main' - : controller.currentSessionKey; + if (!controller.isAppOwnedAssistantSessionKeyInternal( + controller.currentSessionKey, + )) { + await controller.ensureActiveAssistantThreadInternal(); + } + final sessionKey = controller.currentSessionKey.trim(); await controller.enqueueThreadTurnInternal(sessionKey, () async { await controller.ensureDesktopTaskThreadBindingInternal( sessionKey, @@ -383,11 +386,6 @@ bool canQuickConnectGatewayThreadSessionInternal(AppController controller) { profile.mode != defaults.mode; } -String normalizeAssistantSessionKeyThreadInternal(String sessionKey) { - final trimmed = sessionKey.trim(); - return trimmed.isEmpty ? 'main' : trimmed; -} - String joinConnectionPartsThreadSessionInternal(List parts) { final normalized = parts .map((item) => item.trim()) diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index a0f5e38a..ad31452b 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -62,15 +62,19 @@ extension AppControllerDesktopThreadStorage on AppController { } Future ensureActiveAssistantThreadInternal() async { - if (!isAssistantTaskArchived( + final currentKey = normalizedAssistantSessionKeyInternal( sessionsControllerInternal.currentSessionKey, - )) { + ); + if (isAppOwnedAssistantSessionKeyInternal(currentKey) && + !isAssistantTaskArchived(currentKey)) { return; } final fallback = assistantSessionSummariesInternal().firstWhere( - (item) => !isAssistantTaskArchived(item.key), + (item) => + isAppOwnedAssistantSessionKeyInternal(item.key) && + !isAssistantTaskArchived(item.key), orElse: () => GatewaySessionSummary( - key: 'draft:${DateTime.now().millisecondsSinceEpoch}', + key: createAssistantDraftSessionKeyInternal(), kind: 'assistant', displayName: appText('新对话', 'New conversation'), surface: 'Assistant', @@ -92,6 +96,14 @@ extension AppControllerDesktopThreadStorage on AppController { lastMessagePreview: null, ), ); + if (!hasAssistantTaskStateInternal(fallback.key)) { + initializeAssistantThreadContext( + fallback.key, + title: appText('新对话', 'New conversation'), + executionTarget: currentAssistantExecutionTarget, + messageViewMode: currentAssistantMessageViewMode, + ); + } await setCurrentAssistantSessionKeyInternal(fallback.key); } @@ -100,9 +112,9 @@ extension AppControllerDesktopThreadStorage on AppController { appUiState.assistantLastSessionKey, ); final known = - normalized == 'main' || - assistantThreadRecordsInternal.containsKey(normalized) || - assistantThreadMessagesInternal.containsKey(normalized); + isAppOwnedAssistantSessionKeyInternal(normalized) && + (assistantThreadRecordsInternal.containsKey(normalized) || + assistantThreadMessagesInternal.containsKey(normalized)); if (normalized.isEmpty || !known || isAssistantTaskArchived(normalized)) { return; } @@ -325,7 +337,8 @@ extension AppControllerDesktopThreadStorage on AppController { final sessionKey = normalizedAssistantSessionKeyInternal( record.sessionKey, ); - if (record.archived) { + if (!isAppOwnedAssistantSessionKeyInternal(sessionKey) || + record.archived) { continue; } items.add(assistantSessionSummaryForInternal(sessionKey, record: record)); @@ -337,7 +350,9 @@ extension AppControllerDesktopThreadStorage on AppController { final hasCurrent = items.any( (item) => matchesSessionKey(item.key, currentSessionKey), ); - if (!hasCurrent && !isAssistantTaskArchived(currentSessionKey)) { + if (isAppOwnedAssistantSessionKeyInternal(currentSessionKey) && + !hasCurrent && + !isAssistantTaskArchived(currentSessionKey)) { items.add(assistantSessionSummaryForInternal(currentSessionKey)); } diff --git a/lib/app/app_shell_desktop.dart b/lib/app/app_shell_desktop.dart index d28d45a9..f2872a04 100644 --- a/lib/app/app_shell_desktop.dart +++ b/lib/app/app_shell_desktop.dart @@ -51,14 +51,10 @@ class _AppShellState extends State { } List _buildSidebarTaskItems(AppController controller) { - final currentSessionKey = controller.currentSessionKey.trim().isEmpty - ? 'main' - : controller.currentSessionKey.trim(); + final currentSessionKey = controller.currentSessionKey.trim(); return controller.assistantSessions .map((session) { - final sessionKey = session.key.trim().isEmpty - ? 'main' - : session.key.trim(); + final sessionKey = session.key.trim(); final preview = session.lastMessagePreview?.trim() ?? ''; return SidebarTaskItem( sessionKey: sessionKey, diff --git a/lib/runtime/runtime_controllers_derived_tasks.dart b/lib/runtime/runtime_controllers_derived_tasks.dart index da42d963..d807a98e 100644 --- a/lib/runtime/runtime_controllers_derived_tasks.dart +++ b/lib/runtime/runtime_controllers_derived_tasks.dart @@ -147,20 +147,6 @@ class DerivedTasksController extends ChangeNotifier { } } -String normalizeMainSessionKey(String? value) { - final trimmed = value?.trim() ?? ''; - return trimmed.isEmpty ? 'main' : trimmed; -} - -String makeAgentSessionKey({required String agentId, required String baseKey}) { - final trimmedAgent = agentId.trim(); - final trimmedBase = baseKey.trim(); - if (trimmedAgent.isEmpty) { - return normalizeMainSessionKey(trimmedBase); - } - return 'agent:$trimmedAgent:${normalizeMainSessionKey(trimmedBase)}'; -} - bool matchesSessionKey(String incoming, String current) { final left = incoming.trim().toLowerCase(); final right = current.trim().toLowerCase(); diff --git a/lib/runtime/runtime_controllers_gateway.dart b/lib/runtime/runtime_controllers_gateway.dart index 3fb26b2f..aeeded20 100644 --- a/lib/runtime/runtime_controllers_gateway.dart +++ b/lib/runtime/runtime_controllers_gateway.dart @@ -93,8 +93,7 @@ class GatewaySessionsController extends ChangeNotifier { List sessionsInternal = const []; - String currentSessionKeyInternal = 'main'; - String mainSessionBaseKeyInternal = 'main'; + String currentSessionKeyInternal = ''; String selectedAgentIdInternal = ''; String defaultAgentIdInternal = ''; bool loadingInternal = false; @@ -104,37 +103,16 @@ class GatewaySessionsController extends ChangeNotifier { String get currentSessionKey => currentSessionKeyInternal; bool get loading => loadingInternal; String? get error => errorInternal; - String get mainSessionBaseKey => mainSessionBaseKeyInternal; void configure({ - required String mainSessionKey, required String selectedAgentId, required String defaultAgentId, }) { - mainSessionBaseKeyInternal = normalizeMainSessionKey(mainSessionKey); selectedAgentIdInternal = selectedAgentId.trim(); defaultAgentIdInternal = defaultAgentId.trim(); - final preferred = preferredSessionKey; - if (currentSessionKeyInternal.trim().isEmpty || - currentSessionKeyInternal == 'main' || - currentSessionKeyInternal == mainSessionBaseKeyInternal || - currentSessionKeyInternal.startsWith('agent:')) { - currentSessionKeyInternal = preferred; - } notifyListeners(); } - String get preferredSessionKey { - final selected = selectedAgentIdInternal.trim(); - final defaultAgent = defaultAgentIdInternal.trim(); - final base = normalizeMainSessionKey(mainSessionBaseKeyInternal); - if (selected.isEmpty || - (defaultAgent.isNotEmpty && selected == defaultAgent)) { - return base; - } - return makeAgentSessionKey(agentId: selected, baseKey: base); - } - Future refresh() async { if (!runtimeInternal.isConnected) { sessionsInternal = const []; @@ -147,11 +125,6 @@ class GatewaySessionsController extends ChangeNotifier { notifyListeners(); try { sessionsInternal = await runtimeInternal.listSessions(limit: 50); - if (!sessionsInternal.any( - (item) => matchesSessionKey(item.key, currentSessionKeyInternal), - )) { - currentSessionKeyInternal = preferredSessionKey; - } } catch (error) { errorInternal = error.toString(); } finally { diff --git a/test/features/assistant/assistant_artifact_sidebar_test.dart b/test/features/assistant/assistant_artifact_sidebar_test.dart index be06a171..a4366c84 100644 --- a/test/features/assistant/assistant_artifact_sidebar_test.dart +++ b/test/features/assistant/assistant_artifact_sidebar_test.dart @@ -216,7 +216,7 @@ Widget _buildTestApp({ width: 460, height: 640, child: AssistantArtifactSidebar( - sessionKey: 'session-1', + sessionKey: 'draft:test-task-a', threadTitle: 'Thread', workspacePath: '/tmp/thread', workspaceKind: WorkspaceRefKind.localPath, diff --git a/test/features/assistant/assistant_lower_pane_test.dart b/test/features/assistant/assistant_lower_pane_test.dart index f08f9514..2c3ed0c1 100644 --- a/test/features/assistant/assistant_lower_pane_test.dart +++ b/test/features/assistant/assistant_lower_pane_test.dart @@ -13,10 +13,12 @@ void main() { testWidgets( 'does not fabricate providers when live capabilities are unavailable', (tester) async { - final controller = AppController(environmentOverride: const {}); + final controller = AppController( + environmentOverride: const {}, + ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await tester.pumpWidget( _buildTestApp(child: _buildLowerPane(controller: controller)), @@ -29,9 +31,10 @@ void main() { ); expect(find.text('未提供'), findsNothing); - final providerButton = tester.widget>( - find.byKey(const Key('assistant-provider-button')), - ); + final providerButton = tester + .widget>( + find.byKey(const Key('assistant-provider-button')), + ); expect(providerButton.enabled, isFalse); expect( @@ -55,7 +58,7 @@ void main() { testWidgets('shows mode-specific provider catalogs', (tester) async { final controller = AppController( - environmentOverride: const {}, + environmentOverride: const {}, initialBridgeProviderCatalog: const [ SingleAgentProvider.codex, SingleAgentProvider.opencode, @@ -76,7 +79,7 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await tester.pumpWidget( _buildTestApp(child: _buildLowerPane(controller: controller)), @@ -110,7 +113,7 @@ void main() { ); final gatewayThread = controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .copyWith( executionBinding: ExecutionBinding( executionMode: threadExecutionModeFromAssistantExecutionTarget( @@ -151,7 +154,7 @@ void main() { await tester.pumpAndSettle(); final agentThread = controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .copyWith( executionBinding: ExecutionBinding( executionMode: threadExecutionModeFromAssistantExecutionTarget( @@ -204,7 +207,7 @@ void main() { tester, ) async { final controller = AppController( - environmentOverride: const {}, + environmentOverride: const {}, initialBridgeProviderCatalog: const [ SingleAgentProvider.codex, SingleAgentProvider.opencode, @@ -213,7 +216,7 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await tester.pumpWidget( _buildTestApp(child: _buildLowerPane(controller: controller)), @@ -253,7 +256,7 @@ void main() { tester, ) async { final controller = AppController( - environmentOverride: const {}, + environmentOverride: const {}, initialBridgeProviderCatalog: const [ SingleAgentProvider.codex, SingleAgentProvider.opencode, @@ -274,12 +277,12 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); controller.initializeAssistantThreadContext( - 'session-1', + 'draft:test-task-a', executionTarget: AssistantExecutionTarget.gateway, messageViewMode: controller.assistantMessageViewModeForSession( - 'session-1', + 'draft:test-task-a', ), ); controller.notifyListeners(); @@ -315,10 +318,12 @@ void main() { }); testWidgets('uses submit button instead of connect action', (tester) async { - final controller = AppController(environmentOverride: const {}); + final controller = AppController( + environmentOverride: const {}, + ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); var sendCount = 0; diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index 92c1db66..70e54a6e 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -186,9 +186,9 @@ void main() { }); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -201,27 +201,61 @@ void main() { expect( assistantWorkingDirectoryForSessionRuntimeInternal( controller, - 'session-1', + 'draft:test-task-a', ), localWorkspace.path, ); expect( resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal( controller, - 'session-1', + 'draft:test-task-a', ), localWorkspace.path, ); expect( assistantRemoteWorkingDirectoryHintForSessionRuntimeInternal( controller, - 'session-1', + 'draft:test-task-a', ), remoteWorkspace.path, ); }, ); + test('runtime session keys do not resolve to app task workspaces', () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final home = await Directory.systemTemp.createTemp( + 'xworkmate-runtime-key-workspace-', + ); + addTearDown(() async { + if (await home.exists()) { + await home.delete(recursive: true); + } + }); + controller.resolvedUserHomeDirectoryInternal = home.path; + + controller.initializeAssistantThreadContext( + 'draft:test-workspace-task', + executionTarget: AssistantExecutionTarget.gateway, + messageViewMode: AssistantMessageViewMode.rendered, + ); + + expect(controller.localThreadWorkspacePathInternal('session-1'), isEmpty); + expect( + controller.localThreadWorkspaceDisplayPathInternal('session-1'), + isEmpty, + ); + expect(controller.assistantWorkspacePathForSession('session-1'), isEmpty); + expect( + controller.assistantWorkspacePathForSession('draft:test-workspace-task'), + endsWith('/.xworkmate/threads/draft-test-workspace-task'), + ); + }); + test('writes inline ACP artifacts into the local thread workspace', () async { final controller = AppController( environmentOverride: const {}, @@ -238,9 +272,9 @@ void main() { }); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -267,20 +301,20 @@ void main() { ); await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); final artifact = File('${localWorkspace.path}/notes/hello.txt'); expect(await artifact.readAsString(), 'artifact body'); await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); final versionedArtifact = File('${localWorkspace.path}/notes/hello.v2.txt'); expect(await versionedArtifact.readAsString(), 'artifact body'); final snapshot = await controller.loadAssistantArtifactSnapshot( - sessionKey: 'session-1', + sessionKey: 'draft:test-task-a', ); expect(snapshot.resultEntries.map((entry) => entry.relativePath), [ 'notes/hello.v2.txt', @@ -291,7 +325,7 @@ void main() { ); expect( controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .lastArtifactSyncStatus, 'synced', ); @@ -317,9 +351,9 @@ void main() { await staleArtifact.writeAsString('stale task output'); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -346,12 +380,12 @@ void main() { ); await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); final snapshot = await controller.loadAssistantArtifactSnapshot( - sessionKey: 'session-1', + sessionKey: 'draft:test-task-a', ); final currentRelativePaths = snapshot.resultEntries .map((entry) => entry.relativePath) @@ -372,7 +406,7 @@ void main() { previewable: true, workspacePath: localWorkspace.path, ), - sessionKey: 'session-1', + sessionKey: 'draft:test-task-a', ); expect(stalePreview.kind, AssistantArtifactPreviewKind.markdown); expect(stalePreview.content, 'stale task output'); @@ -412,9 +446,9 @@ void main() { }); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -444,7 +478,7 @@ void main() { final clientFactory = _proxiedClientFactory(server.port); await HttpOverrides.runZoned(() async { await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); }, createHttpClient: clientFactory); @@ -453,7 +487,7 @@ void main() { expect(await artifact.readAsString(), 'downloaded artifact body'); expect(observedAuthorization, 'Bearer bridge-token'); final snapshot = await controller.loadAssistantArtifactSnapshot( - sessionKey: 'session-1', + sessionKey: 'draft:test-task-a', ); expect( snapshot.fileEntries.map((entry) => entry.relativePath), @@ -461,7 +495,7 @@ void main() { ); expect( controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .lastArtifactSyncStatus, 'synced', ); @@ -650,9 +684,9 @@ void main() { } }); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -670,7 +704,7 @@ void main() { 'relativePath': 'reports/resume.bin', 'downloadUrl': 'http://xworkmate-bridge.svc.plus:${server.port}/artifacts/openclaw/download' - '?sessionKey=session-1&runId=run-1&relativePath=reports%2Fresume.bin' + '?sessionKey=draft:test-task-a&runId=run-1&relativePath=reports%2Fresume.bin' '&expires=9999999999&sig=test-signature', 'contentType': 'application/octet-stream', 'sizeBytes': body.length, @@ -686,7 +720,7 @@ void main() { final clientFactory = _proxiedClientFactory(server.port); await HttpOverrides.runZoned(() async { await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); }, createHttpClient: clientFactory); @@ -699,7 +733,7 @@ void main() { ); expect( controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .lastArtifactSyncStatus, 'synced', ); @@ -754,9 +788,9 @@ void main() { } }); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -786,7 +820,7 @@ void main() { final clientFactory = _proxiedClientFactory(server.port); await HttpOverrides.runZoned(() async { await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); }, createHttpClient: clientFactory); @@ -798,7 +832,7 @@ void main() { ); expect( controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .lastArtifactSyncStatus, 'synced', ); @@ -837,9 +871,9 @@ void main() { } }); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -880,7 +914,7 @@ void main() { final clientFactory = _proxiedClientFactory(server.port); await HttpOverrides.runZoned(() async { await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); }, createHttpClient: clientFactory); @@ -898,7 +932,7 @@ void main() { isFalse, ); final snapshot = await controller.loadAssistantArtifactSnapshot( - sessionKey: 'session-1', + sessionKey: 'draft:test-task-a', ); expect( snapshot.fileEntries.map((entry) => entry.relativePath), @@ -906,7 +940,7 @@ void main() { ); expect( controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .lastArtifactSyncStatus, 'partial', ); @@ -939,9 +973,9 @@ void main() { } }); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -974,7 +1008,7 @@ void main() { final clientFactory = _proxiedClientFactory(server.port); await HttpOverrides.runZoned(() async { await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); }, createHttpClient: clientFactory); @@ -990,7 +1024,7 @@ void main() { expect(leftovers, isEmpty); expect( controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .lastArtifactSyncStatus, 'download-failed', ); @@ -1013,9 +1047,9 @@ void main() { } }); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -1035,13 +1069,13 @@ void main() { ); await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); expect(await localWorkspace.list(recursive: true).toList(), isEmpty); final thread = controller.requireTaskThreadForSessionInternal( - 'session-1', + 'draft:test-task-a', ); expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts'); expect(thread.lastArtifactSyncAtMs, greaterThan(0)); @@ -1065,9 +1099,9 @@ void main() { final staleArtifact = File('${localWorkspace.path}/old-task-report.md'); await staleArtifact.writeAsString('stale task output'); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -1086,18 +1120,18 @@ void main() { ); await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); expect( controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .lastArtifactSyncStatus, 'no-artifacts', ); final snapshot = await controller.loadAssistantArtifactSnapshot( - sessionKey: 'session-1', + sessionKey: 'draft:test-task-a', ); expect(snapshot.resultEntries, isEmpty); expect( @@ -1124,9 +1158,9 @@ void main() { }); controller.upsertTaskThreadInternal( - 'session-1', + 'draft:test-task-a', workspaceBinding: WorkspaceBinding( - workspaceId: 'session-1', + workspaceId: 'draft:test-task-a', workspaceKind: WorkspaceKind.localFs, workspacePath: localWorkspace.path, displayPath: localWorkspace.path, @@ -1153,7 +1187,7 @@ void main() { ); await controller.persistGoTaskArtifactsForSessionInternal( - 'session-1', + 'draft:test-task-a', result, ); @@ -1163,7 +1197,7 @@ void main() { ); expect( controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .lastArtifactSyncStatus, 'no-artifacts', ); diff --git a/test/runtime/assistant_connection_state_test.dart b/test/runtime/assistant_connection_state_test.dart index e0e4fc56..46ee189a 100644 --- a/test/runtime/assistant_connection_state_test.dart +++ b/test/runtime/assistant_connection_state_test.dart @@ -43,7 +43,7 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); @@ -75,7 +75,7 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); @@ -108,7 +108,7 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); @@ -271,7 +271,7 @@ void main() { final controller = await _isolatedController(); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); @@ -298,7 +298,7 @@ void main() { final controller = await _isolatedController(); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); @@ -320,7 +320,7 @@ void main() { final controller = await _isolatedController(); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); @@ -350,7 +350,7 @@ void main() { final controller = await _isolatedController(); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); @@ -380,7 +380,7 @@ void main() { final controller = await _isolatedController(); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); @@ -407,7 +407,7 @@ void main() { final controller = await _isolatedController(); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index f7593bfe..6aa221da 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -82,7 +82,7 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); expect(controller.currentAssistantExecutionTarget.isAgent, isTrue); expect( @@ -95,14 +95,14 @@ void main() { ); final record = controller.requireTaskThreadForSessionInternal( - 'session-1', + 'draft:test-task-a', ); expect( record.executionBinding.executionMode, ThreadExecutionMode.gateway, ); expect( - controller.assistantProviderForSession('session-1'), + controller.assistantProviderForSession('draft:test-task-a'), SingleAgentProvider.openclaw, ); }, @@ -135,22 +135,25 @@ void main() { addTearDown(controller.dispose); controller.resolvedUserHomeDirectoryInternal = localHome.path; - controller.upsertTaskThreadInternal( - 'main', - executionTarget: AssistantExecutionTarget.gateway, - selectedProvider: SingleAgentProvider.openclaw, - selectedProviderSource: ThreadSelectionSource.explicit, + expect( + () => controller.upsertTaskThreadInternal( + 'main', + executionTarget: AssistantExecutionTarget.gateway, + selectedProvider: SingleAgentProvider.openclaw, + selectedProviderSource: ThreadSelectionSource.explicit, + ), + throwsStateError, ); expect( - controller.assistantExecutionTargetForSession('fresh-task'), + controller.assistantExecutionTargetForSession('draft:fresh-task'), AssistantExecutionTarget.agent, ); - await controller.switchSession('fresh-task'); + await controller.switchSession('draft:fresh-task'); final freshThread = controller.requireTaskThreadForSessionInternal( - 'fresh-task', + 'draft:fresh-task', ); expect( freshThread.executionBinding.executionMode, @@ -158,7 +161,7 @@ void main() { ); expect( freshThread.workspaceBinding.workspacePath, - endsWith('/.xworkmate/threads/fresh-task'), + endsWith('/.xworkmate/threads/draft-fresh-task'), ); }, ); @@ -182,6 +185,121 @@ void main() { expect(second, isNot(first)); }); + test('navigateHome does not select the runtime main session key', () async { + final localHome = await Directory.systemTemp.createTemp( + 'xworkmate-no-runtime-main-home-', + ); + addTearDown(() async { + if (await localHome.exists()) { + await localHome.delete(recursive: true); + } + }); + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + controller.resolvedUserHomeDirectoryInternal = localHome.path; + controller.runtimeInternal.snapshotInternal = controller + .runtimeInternal + .snapshot + .copyWith(mainSessionKey: 'session-1'); + + const taskKey = 'draft:test-home-task'; + await controller.switchSession(taskKey); + + controller.navigateHome(); + await Future.delayed(Duration.zero); + + expect(controller.currentSessionKey, taskKey); + expect( + controller.assistantWorkspacePathForSession(taskKey), + endsWith('/.xworkmate/threads/draft-test-home-task'), + ); + expect(controller.assistantWorkspacePathForSession('session-1'), isEmpty); + expect(controller.taskThreadForSessionInternal('session-1'), isNull); + }); + + test( + 'refreshSessions allocates an app task instead of runtime main when current is stale', + () async { + final localHome = await Directory.systemTemp.createTemp( + 'xworkmate-refresh-no-session-one-', + ); + addTearDown(() async { + if (await localHome.exists()) { + await localHome.delete(recursive: true); + } + }); + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + controller.resolvedUserHomeDirectoryInternal = localHome.path; + controller.runtimeInternal.snapshotInternal = controller + .runtimeInternal + .snapshot + .copyWith(mainSessionKey: 'session-1'); + + await controller.refreshSessions(); + + expect(controller.currentSessionKey, startsWith('draft:')); + expect(controller.currentSessionKey, isNot('session-1')); + expect(controller.currentSessionKey, isNot('main')); + expect( + controller.assistantWorkspacePathForSession( + controller.currentSessionKey, + ), + contains('/.xworkmate/threads/draft-'), + ); + expect( + controller.assistantWorkspacePathForSession('session-1'), + isEmpty, + ); + }, + ); + + test('assistant task list ignores runtime sessions from the gateway', () { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + controller.sessionsControllerInternal.sessionsInternal = + const [ + GatewaySessionSummary( + key: 'session-1', + kind: 'assistant', + displayName: 'runtime session', + surface: 'Assistant', + subject: null, + room: null, + space: null, + updatedAtMs: 1, + sessionId: 'session-1', + systemSent: false, + abortedLastRun: false, + thinkingLevel: null, + verboseLevel: null, + inputTokens: null, + outputTokens: null, + totalTokens: null, + model: null, + contextTokens: null, + derivedTitle: null, + lastMessagePreview: null, + ), + ]; + controller.initializeAssistantThreadContext( + 'draft:test-visible-task', + executionTarget: AssistantExecutionTarget.agent, + messageViewMode: AssistantMessageViewMode.rendered, + ); + + final keys = controller.assistantSessions.map((item) => item.key); + + expect(keys, contains('draft:test-visible-task')); + expect(keys, isNot(contains('session-1'))); + }); + test( 'returns unspecified when a saved provider is no longer in the current catalog', () { @@ -235,17 +353,17 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); final record = controller.requireTaskThreadForSessionInternal( - 'session-1', + 'draft:test-task-a', ); expect( - controller.assistantExecutionTargetForSession('session-1'), + controller.assistantExecutionTargetForSession('draft:test-task-a'), AssistantExecutionTarget.gateway, ); expect(record.executionBinding.providerId, isEmpty); @@ -269,13 +387,13 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); final routing = controller.buildExternalAcpRoutingForSessionInternal( - 'session-1', + 'draft:test-task-a', ); expect(routing.mode, ExternalCodeAgentAcpRoutingMode.explicit); @@ -405,7 +523,7 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await Future.delayed(const Duration(milliseconds: 200)); expect(controller.assistantProviderCatalog, isEmpty); @@ -467,7 +585,7 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); @@ -580,7 +698,7 @@ void main() { ), ); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.agent, ); @@ -618,14 +736,16 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); expect( - controller.hasCommittedUserTurnForGatewaySessionInternal('session-1'), + controller.hasCommittedUserTurnForGatewaySessionInternal( + 'draft:test-task-a', + ), isFalse, ); controller.appendLocalSessionMessageInternal( - 'session-1', + 'draft:test-task-a', GatewayChatMessage( id: 'error-1', role: 'assistant', @@ -641,12 +761,14 @@ void main() { ); expect( - controller.hasCommittedUserTurnForGatewaySessionInternal('session-1'), + controller.hasCommittedUserTurnForGatewaySessionInternal( + 'draft:test-task-a', + ), isFalse, ); controller.appendLocalSessionMessageInternal( - 'session-1', + 'draft:test-task-a', GatewayChatMessage( id: 'assistant-1', role: 'assistant', @@ -662,12 +784,14 @@ void main() { ); expect( - controller.hasCommittedUserTurnForGatewaySessionInternal('session-1'), + controller.hasCommittedUserTurnForGatewaySessionInternal( + 'draft:test-task-a', + ), isFalse, ); controller.appendLocalSessionMessageInternal( - 'session-1', + 'draft:test-task-a', GatewayChatMessage( id: 'user-1', role: 'user', @@ -683,7 +807,9 @@ void main() { ); expect( - controller.hasCommittedUserTurnForGatewaySessionInternal('session-1'), + controller.hasCommittedUserTurnForGatewaySessionInternal( + 'draft:test-task-a', + ), isTrue, ); }, @@ -694,7 +820,7 @@ void main() { final controller = _connectedController(fakeGoTaskService); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.sendChatMessage('first turn'); @@ -714,8 +840,8 @@ void main() { final fakeGoTaskService = _RecordingGoTaskServiceClient() ..updatesBeforeNextOutcome.add( const GoTaskServiceUpdate( - sessionId: 'session-1', - threadId: 'session-1', + sessionId: 'draft:test-task-a', + threadId: 'draft:test-task-a', turnId: 'turn-1', type: 'delta', text: 'partial output that must not persist', @@ -747,7 +873,7 @@ void main() { addTearDown(controller.dispose); controller.resolvedUserHomeDirectoryInternal = localWorkspace.path; - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.sendChatMessage('first turn'); @@ -755,7 +881,7 @@ void main() { expect(fakeGoTaskService.requests.single.resumeSession, isFalse); expect( controller - .taskThreadForSessionInternal('session-1') + .taskThreadForSessionInternal('draft:test-task-a') ?.lifecycleState .status, 'ready', @@ -770,7 +896,7 @@ void main() { ); expect( controller - .taskThreadForSessionInternal('session-1') + .taskThreadForSessionInternal('draft:test-task-a') ?.lastArtifactSyncStatus, 'failed', ); @@ -781,12 +907,14 @@ void main() { expect(fakeGoTaskService.requests.last.resumeSession, isFalse); await _waitForLastChatMessageText(controller, '全部 6 个文件已生成 ✅'); expect(controller.chatMessages.last.text, '全部 6 个文件已生成 ✅'); - final thread = controller.taskThreadForSessionInternal('session-1'); + final thread = controller.taskThreadForSessionInternal( + 'draft:test-task-a', + ); expect(thread?.lifecycleState.status, 'ready'); expect(thread?.lastArtifactSyncStatus, 'synced'); expect(thread?.lastArtifactSyncAtMs, greaterThan(0)); final workspacePath = controller.assistantWorkspacePathForSession( - 'session-1', + 'draft:test-task-a', ); for (final artifact in _generatedArtifactPayloads()) { final relativePath = artifact['relativePath']! as String; @@ -822,14 +950,14 @@ void main() { final controller = _connectedController(fakeGoTaskService); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.sendChatMessage('first turn'); expect(fakeGoTaskService.requests, hasLength(1)); expect(fakeGoTaskService.requests.single.resumeSession, isFalse); final failedThread = controller.taskThreadForSessionInternal( - 'session-1', + 'draft:test-task-a', ); expect(failedThread?.lifecycleState.status, 'ready'); expect( @@ -855,7 +983,9 @@ void main() { controller.chatMessages.last.text, 'retried from a confirmed new start', ); - final thread = controller.taskThreadForSessionInternal('session-1'); + final thread = controller.taskThreadForSessionInternal( + 'draft:test-task-a', + ); expect(thread?.lifecycleState.status, 'ready'); expect(thread?.lifecycleState.lastResultCode, 'success'); }, @@ -877,8 +1007,8 @@ void main() { final fakeGoTaskService = _RecordingGoTaskServiceClient() ..updatesBeforeNextOutcome.add( const GoTaskServiceUpdate( - sessionId: 'session-1', - threadId: 'session-1', + sessionId: 'draft:test-task-a', + threadId: 'draft:test-task-a', turnId: 'turn-1', type: 'delta', text: 'guard partial output must not persist', @@ -910,7 +1040,7 @@ void main() { addTearDown(controller.dispose); controller.resolvedUserHomeDirectoryInternal = localWorkspace.path; - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.sendChatMessage('first turn'); await controller.sendChatMessage('follow up'); @@ -929,7 +1059,9 @@ void main() { isNot(contains('guard partial output must not persist')), ); - final thread = controller.taskThreadForSessionInternal('session-1'); + final thread = controller.taskThreadForSessionInternal( + 'draft:test-task-a', + ); expect(thread?.lifecycleState.status, 'ready'); expect(thread?.lastArtifactSyncStatus, 'no-exported-artifacts'); expect(thread?.lastArtifactSyncAtMs, greaterThan(0)); @@ -952,8 +1084,8 @@ void main() { final fakeGoTaskService = _RecordingGoTaskServiceClient() ..updatesBeforeNextOutcome.add( const GoTaskServiceUpdate( - sessionId: 'session-1', - threadId: 'session-1', + sessionId: 'draft:test-task-a', + threadId: 'draft:test-task-a', turnId: 'turn-1', type: 'delta', text: guardMessage, @@ -985,7 +1117,7 @@ void main() { addTearDown(controller.dispose); controller.resolvedUserHomeDirectoryInternal = localWorkspace.path; - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.sendChatMessage('create files'); final transcript = controller.chatMessages @@ -993,7 +1125,9 @@ void main() { .join('\n'); expect(transcript, isNot(contains('未检测到 OpenClaw 本轮导出的实际文件'))); expect(transcript, isNot(contains('口头下载声明'))); - final thread = controller.taskThreadForSessionInternal('session-1'); + final thread = controller.taskThreadForSessionInternal( + 'draft:test-task-a', + ); expect(thread?.lifecycleState.lastResultCode, 'artifact_missing'); expect(thread?.lastArtifactSyncStatus, 'no-exported-artifacts'); expect(thread?.lastArtifactSyncAtMs, greaterThan(0)); @@ -1014,8 +1148,8 @@ void main() { final fakeGoTaskService = _RecordingGoTaskServiceClient() ..updatesBeforeNextOutcome.add( const GoTaskServiceUpdate( - sessionId: 'session-1', - threadId: 'session-1', + sessionId: 'draft:test-task-a', + threadId: 'draft:test-task-a', turnId: 'turn-1', type: 'delta', text: 'handshake partial output must not persist', @@ -1047,14 +1181,14 @@ void main() { addTearDown(controller.dispose); controller.resolvedUserHomeDirectoryInternal = localWorkspace.path; - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.sendChatMessage('first turn'); expect(fakeGoTaskService.requests, hasLength(1)); expect(fakeGoTaskService.requests.single.resumeSession, isFalse); final failedThread = controller.taskThreadForSessionInternal( - 'session-1', + 'draft:test-task-a', ); expect(failedThread?.lifecycleState.status, 'ready'); expect( @@ -1077,12 +1211,14 @@ void main() { expect(fakeGoTaskService.requests.last.resumeSession, isFalse); await _waitForLastChatMessageText(controller, '全部 6 个文件已生成 ✅'); expect(controller.chatMessages.last.text, '全部 6 个文件已生成 ✅'); - final thread = controller.taskThreadForSessionInternal('session-1'); + final thread = controller.taskThreadForSessionInternal( + 'draft:test-task-a', + ); expect(thread?.lifecycleState.status, 'ready'); expect(thread?.lastArtifactSyncStatus, 'synced'); expect(thread?.lastArtifactSyncAtMs, greaterThan(0)); final workspacePath = controller.assistantWorkspacePathForSession( - 'session-1', + 'draft:test-task-a', ); for (final artifact in _generatedArtifactPayloads()) { final relativePath = artifact['relativePath']! as String; @@ -1103,7 +1239,7 @@ void main() { ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); final userMessage = GatewayChatMessage( id: 'local-user-1', @@ -1129,19 +1265,19 @@ void main() { ); controller.appendLocalSessionMessageInternal( - 'session-1', + 'draft:test-task-a', userMessage, persistInThreadContext: true, ); controller.appendLocalSessionMessageInternal( - 'session-1', + 'draft:test-task-a', assistantMessage, persistInThreadContext: true, ); - controller.assistantThreadMessagesInternal['session-1'] = + controller.assistantThreadMessagesInternal['draft:test-task-a'] = List.from( controller - .requireTaskThreadForSessionInternal('session-1') + .requireTaskThreadForSessionInternal('draft:test-task-a') .messages, ); diff --git a/test/runtime/assistant_model_display_test.dart b/test/runtime/assistant_model_display_test.dart index faa9cfe2..2eb5aae9 100644 --- a/test/runtime/assistant_model_display_test.dart +++ b/test/runtime/assistant_model_display_test.dart @@ -5,10 +5,12 @@ import 'package:xworkmate/runtime/runtime_models.dart'; void main() { group('Assistant model display', () { test('hides stale model display when no runtime model matches', () async { - final controller = AppController(environmentOverride: const {}); + final controller = AppController( + environmentOverride: const {}, + ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); expect(controller.resolvedAssistantModel, isNotEmpty); expect(controller.assistantModelChoices, isEmpty); @@ -23,10 +25,12 @@ void main() { test( 'shows matched runtime model when gateway catalog is available', () async { - final controller = AppController(environmentOverride: const {}); + final controller = AppController( + environmentOverride: const {}, + ); addTearDown(controller.dispose); - await controller.sessionsController.switchSession('session-1'); + await controller.sessionsController.switchSession('draft:test-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, );