diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index e49ff029..3ab9ec39 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -328,8 +328,6 @@ class AppController extends ChangeNotifier { DesktopTaskThreadRepository(saveRecords: storeInternal.saveTaskThreads); final Map> localSessionMessagesInternal = >{}; - final Map> gatewayHistoryCacheInternal = - >{}; final Map aiGatewayStreamingTextBySessionInternal = {}; final DesktopThreadArtifactService threadArtifactServiceInternal = diff --git a/lib/app/app_controller_desktop_settings.dart b/lib/app/app_controller_desktop_settings.dart index 88386688..59cec4ab 100644 --- a/lib/app/app_controller_desktop_settings.dart +++ b/lib/app/app_controller_desktop_settings.dart @@ -292,7 +292,6 @@ extension AppControllerDesktopSettings on AppController { taskThreadRepositoryInternal.clear(); assistantThreadMessagesInternal.clear(); localSessionMessagesInternal.clear(); - gatewayHistoryCacheInternal.clear(); aiGatewayStreamingTextBySessionInternal.clear(); aiGatewayStreamingClientsInternal.clear(); aiGatewayPendingSessionKeysInternal.clear(); diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index fe674712..30768785 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -194,15 +194,10 @@ extension AppControllerDesktopThreadActions on AppController { } Future switchSession(String sessionKey) async { - final previousSessionKey = normalizedAssistantSessionKeyInternal( - sessionsControllerInternal.currentSessionKey, - ); final nextSessionKey = normalizedAssistantSessionKeyInternal(sessionKey); final nextTarget = assistantExecutionTargetForSession(nextSessionKey); final nextViewMode = assistantMessageViewModeForSession(nextSessionKey); - preserveGatewayHistoryForSessionInternal(previousSessionKey); - await setCurrentAssistantSessionKeyInternal(nextSessionKey); upsertTaskThreadInternal( nextSessionKey, @@ -220,6 +215,11 @@ extension AppControllerDesktopThreadActions on AppController { persistDefaultSelection: false, preserveGatewayHistoryForSelectedThread: false, ); + if (runtimeInternal.isConnected) { + await chatControllerInternal.loadSession(nextSessionKey); + } else { + chatControllerInternal.resetSession(nextSessionKey); + } recomputeTasksInternal(); } @@ -731,6 +731,13 @@ extension AppControllerDesktopThreadActions on AppController { ), persistInThreadContext: true, ); + upsertTaskThreadInternal( + sessionKey, + lifecycleStatus: 'ready', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: gatewayTerminalResultCodeInternal(result), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); recomputeTasksInternal(); notifyIfActiveInternal(); await persistGoTaskArtifactsForSessionInternal(sessionKey, result); diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 04fb7714..e71612bb 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -473,9 +473,12 @@ extension AppControllerDesktopThreadSessions on AppController { final sessionKey = normalizedAssistantSessionKeyInternal( sessionsControllerInternal.currentSessionKey, ); - final items = List.from( - chatControllerInternal.messages, + 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); @@ -484,8 +487,9 @@ extension AppControllerDesktopThreadSessions on AppController { if (localItems != null && localItems.isNotEmpty) { items.addAll(localItems); } - final streaming = - chatControllerInternal.streamingAssistantText?.trim() ?? ''; + final streaming = matchesSessionKey(chatSessionKey, sessionKey) + ? chatControllerInternal.streamingAssistantText?.trim() ?? '' + : ''; if (streaming.isNotEmpty) { items.add( GatewayChatMessage( diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index aee301e4..a0f5e38a 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -318,16 +318,6 @@ extension AppControllerDesktopThreadStorage on AppController { notifyIfActiveInternal(); } - void preserveGatewayHistoryForSessionInternal(String sessionKey) { - final key = normalizedAssistantSessionKeyInternal(sessionKey); - if (chatControllerInternal.messages.isEmpty) { - return; - } - gatewayHistoryCacheInternal[key] = List.from( - chatControllerInternal.messages, - ); - } - List assistantSessionSummariesInternal() { final items = []; diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart index 276da2c7..23edc671 100644 --- a/lib/features/assistant/assistant_page_state_closure.dart +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -113,13 +113,14 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { (defaultComposerHeight + workspaceLowerPaneHeightAdjustmentInternal) .clamp(composerHeightLowerBound, composerHeightUpperBound) .toDouble(); + final activeSessionKey = currentTask.sessionKey.trim().isEmpty + ? controller.currentSessionKey + : currentTask.sessionKey.trim(); final thread = controller.taskThreadForSessionInternal( - controller.currentSessionKey, + activeSessionKey, ); final progressState = assistantTaskProgressState( - pending: controller.assistantSessionHasPendingRun( - controller.currentSessionKey, - ), + pending: controller.assistantSessionHasPendingRun(activeSessionKey), lifecycleStatus: thread?.lifecycleState.status ?? '', lastResultCode: thread?.lifecycleState.lastResultCode ?? '', artifactSyncStatus: thread?.lastArtifactSyncStatus ?? '', @@ -144,7 +145,8 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { controller: controller, currentTask: currentTask, items: timelineItems, - messageViewMode: controller.currentAssistantMessageViewMode, + messageViewMode: controller + .assistantMessageViewModeForSession(activeSessionKey), bottomContentInset: composerBottomSpacing, topTrailingInset: artifactPaneCollapsedInternal ? assistantCollapsedArtifactToggleClearanceInternal @@ -208,7 +210,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { thinkingLabel: thinkingLabelInternal, showModelControl: true, modelLabel: controller.assistantDisplayModelForSession( - controller.currentSessionKey, + activeSessionKey, ), modelOptions: controller.assistantModelChoices, attachments: attachmentsInternal, @@ -229,7 +231,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { onToggleSkill: (key) { unawaited( controller.toggleAssistantSkillForSession( - controller.currentSessionKey, + activeSessionKey, key, ), ); @@ -242,7 +244,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { }, onModelChanged: (modelId) => controller.selectAssistantModelForSession( - controller.currentSessionKey, + activeSessionKey, modelId, ), onPickAttachments: AssistantPageStateActionsInternal( @@ -293,6 +295,9 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { final paneWidth = artifactPaneWidthInternal .clamp(assistantArtifactPaneMinWidthInternal, maxPaneWidth) .toDouble(); + final activeSessionKey = currentTask.sessionKey.trim().isEmpty + ? controller.currentSessionKey + : currentTask.sessionKey.trim(); final panel = Row( children: [ Expanded(child: child), @@ -322,23 +327,19 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { SizedBox( width: paneWidth, child: AssistantArtifactSidebar( - sessionKey: controller.currentSessionKey, + sessionKey: activeSessionKey, threadTitle: currentTask.title, workspacePath: controller .assistantWorkspaceDisplayPathForSession( - controller.currentSessionKey, + activeSessionKey, ), workspaceKind: controller.assistantWorkspaceKindForSession( - controller.currentSessionKey, + activeSessionKey, ), artifactSyncAtMs: controller - .assistantArtifactSyncAtMsForSession( - controller.currentSessionKey, - ), + .assistantArtifactSyncAtMsForSession(activeSessionKey), artifactSyncStatus: controller - .assistantArtifactSyncStatusForSession( - controller.currentSessionKey, - ), + .assistantArtifactSyncStatusForSession(activeSessionKey), onCollapse: () { setState(() { artifactPaneCollapsedInternal = true; @@ -346,9 +347,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { }, onOpenWorkspace: () async { final workspacePath = controller - .assistantWorkspacePathForSession( - controller.currentSessionKey, - ) + .assistantWorkspacePathForSession(activeSessionKey) .trim(); if (workspacePath.isEmpty) { return; @@ -369,9 +368,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { }, onOpenEntryLocation: (entry) async { final workspacePath = controller - .assistantWorkspacePathForSession( - controller.currentSessionKey, - ) + .assistantWorkspacePathForSession(activeSessionKey) .trim(); if (workspacePath.isEmpty) { return; @@ -398,10 +395,14 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { ]); } }, - loadSnapshot: () => - controller.loadAssistantArtifactSnapshot(), + loadSnapshot: () => controller.loadAssistantArtifactSnapshot( + sessionKey: activeSessionKey, + ), loadPreview: (entry) => - controller.loadAssistantArtifactPreview(entry), + controller.loadAssistantArtifactPreview( + entry, + sessionKey: activeSessionKey, + ), ), ), ], diff --git a/lib/runtime/runtime_controllers_gateway.dart b/lib/runtime/runtime_controllers_gateway.dart index 9ca05248..12a993ca 100644 --- a/lib/runtime/runtime_controllers_gateway.dart +++ b/lib/runtime/runtime_controllers_gateway.dart @@ -237,6 +237,15 @@ class GatewayChatController extends ChangeNotifier { notifyListeners(); } + void resetSession(String sessionKey) { + sessionKeyInternal = sessionKey.trim().isEmpty ? 'main' : sessionKey.trim(); + messagesInternal = const []; + pendingRunsInternal.clear(); + streamingAssistantTextInternal = null; + errorInternal = null; + notifyListeners(); + } + void handleChatRunEventInternal(Map payload) { final runId = stringValue(payload['runId']); final state = stringValue(payload['state']) ?? ''; @@ -249,18 +258,15 @@ class GatewayChatController extends ChangeNotifier { } final assistantText = stringValue(payload['assistantText']) ?? ''; - if (assistantText.isNotEmpty && - (state == 'delta' || state == 'final')) { + if (assistantText.isNotEmpty && (state == 'delta' || state == 'final')) { streamingAssistantTextInternal = assistantText; } if (state == 'error') { errorInternal = stringValue(payload['errorMessage']) ?? 'Chat failed'; } final terminal = - boolValue(payload['terminal']) ?? false || - state == 'final' || - state == 'aborted' || - state == 'error'; + boolValue(payload['terminal']) ?? + false || state == 'final' || state == 'aborted' || state == 'error'; if (terminal) { if (runId != null) { pendingRunsInternal.remove(runId); diff --git a/test/features/assistant/assistant_page_session_binding_test.dart b/test/features/assistant/assistant_page_session_binding_test.dart new file mode 100644 index 00000000..68fdbacf --- /dev/null +++ b/test/features/assistant/assistant_page_session_binding_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/features/assistant/assistant_page_main.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/theme/app_theme.dart'; + +void main() { + testWidgets('does not render conversation messages from another session', ( + tester, + ) async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('current-session'); + controller.localSessionMessagesInternal['current-session'] = + const [ + GatewayChatMessage( + id: 'current-local', + role: 'assistant', + text: 'current session message', + timestampMs: 1, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ]; + controller.chatController + ..sessionKeyInternal = 'stale-session' + ..messagesInternal = const [ + GatewayChatMessage( + id: 'stale-gateway', + role: 'assistant', + text: 'stale gateway message', + timestampMs: 2, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ]; + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Material( + child: SizedBox( + width: 1280, + height: 760, + child: AssistantPage( + controller: controller, + showStandaloneTaskRail: false, + onOpenDetail: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('current session message'), findsAtLeastNWidgets(1)); + expect(find.text('stale gateway message'), findsNothing); + }); +} diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index a4440566..92c1db66 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -10,6 +10,91 @@ import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; void main() { + test('does not expose gateway chat messages from another session', () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('current-session'); + controller.localSessionMessagesInternal['current-session'] = + const [ + GatewayChatMessage( + id: 'current-local', + role: 'assistant', + text: 'current session message', + timestampMs: 1, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ]; + controller.chatController + ..sessionKeyInternal = 'stale-session' + ..messagesInternal = const [ + GatewayChatMessage( + id: 'stale-gateway', + role: 'assistant', + text: 'stale gateway message', + timestampMs: 2, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ] + ..streamingAssistantTextInternal = 'stale streaming message'; + + expect( + controller.chatMessages.map((message) => message.text), + contains('current session message'), + ); + expect( + controller.chatMessages.map((message) => message.text), + isNot(contains('stale gateway message')), + ); + expect( + controller.chatMessages.map((message) => message.text), + isNot(contains('stale streaming message')), + ); + }); + + test('switchSession resets the gateway chat session boundary', () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('stale-session'); + controller.chatController + ..sessionKeyInternal = 'stale-session' + ..messagesInternal = const [ + GatewayChatMessage( + id: 'stale-gateway', + role: 'assistant', + text: 'stale gateway message', + timestampMs: 1, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ]; + + await controller.switchSession('current-session'); + + expect(controller.currentSessionKey, 'current-session'); + expect(controller.chatController.sessionKey, 'current-session'); + expect( + controller.chatMessages.map((message) => message.text), + isNot(contains('stale gateway message')), + ); + }); + test( 'converges managed local thread workspaces to the user home root', () async {