Fix assistant session artifact binding
This commit is contained in:
parent
1d40618eca
commit
95fdaefb74
@ -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 =
|
||||
|
||||
@ -292,7 +292,6 @@ extension AppControllerDesktopSettings on AppController {
|
||||
taskThreadRepositoryInternal.clear();
|
||||
assistantThreadMessagesInternal.clear();
|
||||
localSessionMessagesInternal.clear();
|
||||
gatewayHistoryCacheInternal.clear();
|
||||
aiGatewayStreamingTextBySessionInternal.clear();
|
||||
aiGatewayStreamingClientsInternal.clear();
|
||||
aiGatewayPendingSessionKeysInternal.clear();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>[];
|
||||
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user