Fix assistant session artifact binding

This commit is contained in:
Haitao Pan 2026-05-12 16:47:09 +08:00
parent 1d40618eca
commit 95fdaefb74
9 changed files with 213 additions and 54 deletions

View File

@ -328,8 +328,6 @@ class AppController extends ChangeNotifier {
DesktopTaskThreadRepository(saveRecords: storeInternal.saveTaskThreads);
final Map<String, List<GatewayChatMessage>> localSessionMessagesInternal =
<String, List<GatewayChatMessage>>{};
final Map<String, List<GatewayChatMessage>> gatewayHistoryCacheInternal =
<String, List<GatewayChatMessage>>{};
final Map<String, String> aiGatewayStreamingTextBySessionInternal =
<String, String>{};
final DesktopThreadArtifactService threadArtifactServiceInternal =

View File

@ -292,7 +292,6 @@ extension AppControllerDesktopSettings on AppController {
taskThreadRepositoryInternal.clear();
assistantThreadMessagesInternal.clear();
localSessionMessagesInternal.clear();
gatewayHistoryCacheInternal.clear();
aiGatewayStreamingTextBySessionInternal.clear();
aiGatewayStreamingClientsInternal.clear();
aiGatewayPendingSessionKeysInternal.clear();

View File

@ -194,15 +194,10 @@ extension AppControllerDesktopThreadActions on AppController {
}
Future<void> 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);

View File

@ -473,9 +473,12 @@ extension AppControllerDesktopThreadSessions on AppController {
final sessionKey = normalizedAssistantSessionKeyInternal(
sessionsControllerInternal.currentSessionKey,
);
final items = List<GatewayChatMessage>.from(
chatControllerInternal.messages,
final chatSessionKey = normalizedAssistantSessionKeyInternal(
chatControllerInternal.sessionKey,
);
final items = matchesSessionKey(chatSessionKey, sessionKey)
? List<GatewayChatMessage>.from(chatControllerInternal.messages)
: <GatewayChatMessage>[];
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(

View File

@ -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<GatewayChatMessage>.from(
chatControllerInternal.messages,
);
}
List<GatewaySessionSummary> assistantSessionSummariesInternal() {
final items = <GatewaySessionSummary>[];

View File

@ -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,
),
),
),
],

View File

@ -237,6 +237,15 @@ class GatewayChatController extends ChangeNotifier {
notifyListeners();
}
void resetSession(String sessionKey) {
sessionKeyInternal = sessionKey.trim().isEmpty ? 'main' : sessionKey.trim();
messagesInternal = const <GatewayChatMessage>[];
pendingRunsInternal.clear();
streamingAssistantTextInternal = null;
errorInternal = null;
notifyListeners();
}
void handleChatRunEventInternal(Map<String, dynamic> 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);

View File

@ -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 <String, String>{},
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('current-session');
controller.localSessionMessagesInternal['current-session'] =
const <GatewayChatMessage>[
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>[
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);
});
}

View File

@ -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 <String, String>{},
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('current-session');
controller.localSessionMessagesInternal['current-session'] =
const <GatewayChatMessage>[
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>[
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 <String, String>{},
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('stale-session');
controller.chatController
..sessionKeyInternal = 'stale-session'
..messagesInternal = const <GatewayChatMessage>[
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 {