Remove runtime session task binding

This commit is contained in:
Haitao Pan 2026-05-13 12:37:34 +08:00
parent 71b78dae66
commit 87c978d456
19 changed files with 435 additions and 261 deletions

View File

@ -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());
}
}

View File

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

View File

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

View File

@ -540,7 +540,6 @@ extension AppControllerDesktopSettingsRuntime on AppController {
'',
);
sessionsControllerInternal.configure(
mainSessionKey: runtimeInternal.snapshot.mainSessionKey ?? 'main',
selectedAgentId: agentsControllerInternal.selectedAgentId,
defaultAgentId: '',
);

View File

@ -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) {

View File

@ -144,7 +144,6 @@ extension AppControllerDesktopThreadActions on AppController {
Future<void> 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<void> 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<void> 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 <CollaborationAttachment>[],
List<String> selectedSkillLabels = const <String>[],
}) 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,

View File

@ -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)}';
}

View File

@ -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<GatewaySessionSummary> assistantSessionsInternal() {
final byKey = <String, GatewaySessionSummary>{};
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);
}

View File

@ -102,9 +102,12 @@ Future<void> runMultiAgentCollaborationThreadSessionInternal(
required List<CollaborationAttachment> attachments,
required List<String> 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<void>(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<String> parts) {
final normalized = parts
.map((item) => item.trim())

View File

@ -62,15 +62,19 @@ extension AppControllerDesktopThreadStorage on AppController {
}
Future<void> 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));
}

View File

@ -51,14 +51,10 @@ class _AppShellState extends State<AppShell> {
}
List<SidebarTaskItem> _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,

View File

@ -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();

View File

@ -93,8 +93,7 @@ class GatewaySessionsController extends ChangeNotifier {
List<GatewaySessionSummary> sessionsInternal =
const <GatewaySessionSummary>[];
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<void> refresh() async {
if (!runtimeInternal.isConnected) {
sessionsInternal = const <GatewaySessionSummary>[];
@ -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 {

View File

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

View File

@ -13,10 +13,12 @@ void main() {
testWidgets(
'does not fabricate providers when live capabilities are unavailable',
(tester) async {
final controller = AppController(environmentOverride: const <String, String>{});
final controller = AppController(
environmentOverride: const <String, String>{},
);
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<PopupMenuButton<SingleAgentProvider>>(
find.byKey(const Key('assistant-provider-button')),
);
final providerButton = tester
.widget<PopupMenuButton<SingleAgentProvider>>(
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 <String, String>{},
environmentOverride: const <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
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 <String, String>{},
environmentOverride: const <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
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 <String, String>{},
environmentOverride: const <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
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 <String, String>{});
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await controller.sessionsController.switchSession('draft:test-task-a');
var sendCount = 0;

View File

@ -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 <String, String>{},
);
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 <String, String>{},
@ -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), <String>[
'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',
);

View File

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

View File

@ -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 <String, String>{},
);
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<void>.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 <String, String>{},
);
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 <String, String>{},
);
addTearDown(controller.dispose);
controller.sessionsControllerInternal.sessionsInternal =
const <GatewaySessionSummary>[
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<void>.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<GatewayChatMessage>.from(
controller
.requireTaskThreadForSessionInternal('session-1')
.requireTaskThreadForSessionInternal('draft:test-task-a')
.messages,
);

View File

@ -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 <String, String>{});
final controller = AppController(
environmentOverride: const <String, String>{},
);
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 <String, String>{});
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await controller.sessionsController.switchSession('draft:test-task-a');
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);