fix: remove polluted test task sessions

This commit is contained in:
Haitao Pan 2026-05-19 08:21:45 +08:00
parent 7ff53a1246
commit 484b10ecf5
10 changed files with 444 additions and 159 deletions

View File

@ -137,7 +137,21 @@ class AppController extends ChangeNotifier {
GoTaskServiceClient? goTaskServiceClient,
MultiAgentMountManager? multiAgentMountManager,
}) {
storeInternal = store ?? SecureConfigStore();
environmentOverrideInternal = environmentOverride == null
? null
: Map<String, String>.unmodifiable(environmentOverride);
if (environmentOverrideInternal != null) {
resolvedUserHomeDirectoryInternal =
resolveUserHomeDirectoryFromControllerEnvironmentInternal(
environmentOverrideInternal,
);
}
storeInternal =
store ??
createDefaultSecureConfigStoreForControllerEnvironmentInternal(
resolvedUserHomeDirectoryInternal,
environmentOverride: environmentOverrideInternal,
);
uiFeatureManifestInternal =
uiFeatureManifest ?? loadRepoUiFeatureManifestSyncInternal();
hostUiFeaturePlatformInternal = Platform.isIOS || Platform.isAndroid
@ -196,15 +210,6 @@ class AppController extends ChangeNotifier {
skillDirectoryAccessService ?? createSkillDirectoryAccessService();
singleAgentSharedSkillScanRootOverridesInternal =
singleAgentSharedSkillScanRootOverrides?.toList(growable: false);
environmentOverrideInternal = environmentOverride == null
? null
: Map<String, String>.unmodifiable(environmentOverride);
if (environmentOverrideInternal != null) {
resolvedUserHomeDirectoryInternal =
resolveUserHomeDirectoryFromControllerEnvironmentInternal(
environmentOverrideInternal,
);
}
gatewayAcpClientInternal = GatewayAcpClient(
endpointResolver: resolveGatewayAcpEndpointInternal,
authorizationResolver: resolveGatewayAcpAuthorizationHeaderInternal,
@ -739,3 +744,28 @@ String resolveUserHomeDirectoryFromControllerEnvironmentInternal(
}
return '';
}
SecureConfigStore
createDefaultSecureConfigStoreForControllerEnvironmentInternal(
String resolvedUserHomeDirectory, {
required Map<String, String>? environmentOverride,
}) {
if (environmentOverride == null || resolvedUserHomeDirectory.trim().isEmpty) {
return SecureConfigStore();
}
final supportRoot =
defaultUserSettingsRootPath(
environment: <String, String>{
...environmentOverride,
'HOME': resolvedUserHomeDirectory,
},
operatingSystem: Platform.operatingSystem,
) ??
'${resolvedUserHomeDirectory.trim()}/.xworkmate';
return SecureConfigStore(
appDataRootPathResolver: () async => supportRoot,
supportRootPathResolver: () async => supportRoot,
secretRootPathResolver: () async => '$supportRoot/secrets',
enableSecureStorage: false,
);
}

View File

@ -471,6 +471,11 @@ extension AppControllerDesktopSettingsRuntime on AppController {
await storeInternal.saveAppUiState(sanitizedAppUiState);
}
final storedAssistantThreads = await storeInternal.loadTaskThreads();
final sanitizedAssistantThreads =
discardKnownPollutedTestTaskThreadsInternal(storedAssistantThreads);
if (sanitizedAssistantThreads.length != storedAssistantThreads.length) {
await storeInternal.saveTaskThreads(sanitizedAssistantThreads);
}
final skippedInvalidThreadRecords =
storeInternal.lastSkippedInvalidTaskThreadRecords;
startupTaskThreadWarningInternal = skippedInvalidThreadRecords.isEmpty
@ -514,7 +519,7 @@ extension AppControllerDesktopSettingsRuntime on AppController {
} catch (_) {
// Keep initialization resilient when remote account restore fails.
}
restoreAssistantThreadsInternal(storedAssistantThreads);
restoreAssistantThreadsInternal(sanitizedAssistantThreads);
await restoreSharedSingleAgentLocalSkillsCacheInternal();
if (disposedInternal) {
return;

View File

@ -45,6 +45,31 @@ import 'app_controller_desktop_runtime_helpers.dart';
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
extension AppControllerDesktopThreadStorage on AppController {
Set<String> knownPollutedTestTaskSessionKeysInternal() => <String>{
'draft'
':unit-task-a',
'draft'
':test-task-a',
'test-fixture:unit-task-a',
'test-fixture:test-task-a',
};
bool isKnownPollutedTestTaskSessionKeyInternal(String sessionKey) {
final normalized = normalizedAssistantSessionKeyInternal(sessionKey);
return knownPollutedTestTaskSessionKeysInternal().contains(normalized);
}
List<TaskThread> discardKnownPollutedTestTaskThreadsInternal(
List<TaskThread> records,
) {
return records
.where(
(record) =>
!isKnownPollutedTestTaskSessionKeyInternal(record.sessionKey),
)
.toList(growable: false);
}
Future<void> applyPersistedAiGatewaySettingsInternal(
SettingsSnapshot snapshot,
) async {
@ -205,7 +230,14 @@ extension AppControllerDesktopThreadStorage on AppController {
);
})
.toList(growable: false);
return state.copyWith(assistantNavigationDestinations: allowedNavigation);
final assistantLastSessionKey =
isKnownPollutedTestTaskSessionKeyInternal(state.assistantLastSessionKey)
? ''
: state.assistantLastSessionKey;
return state.copyWith(
assistantLastSessionKey: assistantLastSessionKey,
assistantNavigationDestinations: allowedNavigation,
);
}
SettingsSnapshot sanitizeOllamaCloudSettingsInternal(
@ -716,6 +748,9 @@ extension AppControllerDesktopThreadStorage on AppController {
if (sessionKey.isEmpty) {
continue;
}
if (isKnownPollutedTestTaskSessionKeyInternal(sessionKey)) {
continue;
}
if (!record.workspaceBinding.isComplete) {
continue;
}

View File

@ -216,7 +216,7 @@ Widget _buildTestApp({
width: 460,
height: 640,
child: AssistantArtifactSidebar(
sessionKey: 'draft:unit-task-a',
sessionKey: 'unit-fixture-task-a',
threadTitle: 'Thread',
workspacePath: '/tmp/thread',
workspaceKind: WorkspaceRefKind.localPath,

View File

@ -18,7 +18,9 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await tester.pumpWidget(
_buildTestApp(child: _buildLowerPane(controller: controller)),
@ -79,7 +81,7 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession('unit-fixture-task-a');
await tester.pumpWidget(
_buildTestApp(child: _buildLowerPane(controller: controller)),
@ -113,7 +115,7 @@ void main() {
);
final gatewayThread = controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.copyWith(
executionBinding: ExecutionBinding(
executionMode: threadExecutionModeFromAssistantExecutionTarget(
@ -154,7 +156,7 @@ void main() {
await tester.pumpAndSettle();
final agentThread = controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.copyWith(
executionBinding: ExecutionBinding(
executionMode: threadExecutionModeFromAssistantExecutionTarget(
@ -216,7 +218,7 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession('unit-fixture-task-a');
await tester.pumpWidget(
_buildTestApp(child: _buildLowerPane(controller: controller)),
@ -277,12 +279,12 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession('unit-fixture-task-a');
controller.initializeAssistantThreadContext(
'draft:unit-task-a',
'unit-fixture-task-a',
executionTarget: AssistantExecutionTarget.gateway,
messageViewMode: controller.assistantMessageViewModeForSession(
'draft:unit-task-a',
'unit-fixture-task-a',
),
);
controller.notifyListeners();
@ -331,12 +333,14 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
controller.initializeAssistantThreadContext(
'draft:unit-task-a',
'unit-fixture-task-a',
executionTarget: AssistantExecutionTarget.gateway,
messageViewMode: controller.assistantMessageViewModeForSession(
'draft:unit-task-a',
'unit-fixture-task-a',
),
);
controller.notifyListeners();
@ -367,7 +371,7 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession('unit-fixture-task-a');
var sendCount = 0;

View File

@ -8,8 +8,112 @@ import 'package:xworkmate/app/app_controller_desktop_thread_binding.dart';
import 'package:xworkmate/runtime/assistant_artifacts.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
void main() {
test(
'startup removes known test task pollution and preserves real history',
() async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-test-pollution-store-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
await storeRoot.delete(recursive: true);
}
});
final store = _RecordingSecureConfigStore(rootPath: storeRoot.path);
await store.initialize();
final pollutedSessionKey = _pollutedUnitSessionKey();
const realSessionKey = 'real-history-session';
await store.saveTaskThreads(<TaskThread>[
_persistedThread(
sessionKey: pollutedSessionKey,
title: 'Unit test fixture',
workspacePath:
'${storeRoot.path}/home/.xworkmate/threads/${_pollutedUnitWorkspaceName()}',
),
_persistedThread(
sessionKey: realSessionKey,
title: 'Real history task',
workspacePath:
'${storeRoot.path}/home/.xworkmate/threads/real-history-session',
),
]);
await store.saveAppUiState(
AppUiState.defaults().copyWith(
assistantLastSessionKey: pollutedSessionKey,
),
);
final controller = AppController(
store: store,
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
await _waitForControllerInitialization(controller);
expect(
controller.taskThreadForSessionInternal(pollutedSessionKey),
isNull,
);
expect(
controller.assistantSessions.map((item) => item.key),
allOf(contains(realSessionKey), isNot(contains(pollutedSessionKey))),
);
expect(controller.currentSessionKey, isNot(pollutedSessionKey));
expect(controller.appUiState.assistantLastSessionKey, isEmpty);
expect(store.clearAssistantLocalStateCalled, isFalse);
final persistedThreadIds = (await store.loadTaskThreads())
.map((thread) => thread.threadId)
.toList(growable: false);
expect(persistedThreadIds, <String>[realSessionKey]);
expect((await store.loadAppUiState()).assistantLastSessionKey, isEmpty);
},
);
test('source tree does not contain known real draft test fixtures', () async {
final blocked = <String>[
_pollutedUnitSessionKey(),
_pollutedTestSessionKey(),
_pollutedUnitWorkspaceName(),
_pollutedTestWorkspaceName(),
];
final roots = <String>['lib', 'test', 'scripts', 'docs'];
final violations = <String>[];
for (final root in roots) {
final directory = Directory(root);
if (!await directory.exists()) {
continue;
}
await for (final entity in directory.list(recursive: true)) {
if (entity is! File) {
continue;
}
final path = entity.path;
if (path.contains('/build/') || path.contains('/.dart_tool/')) {
continue;
}
String content;
try {
content = await entity.readAsString();
} catch (_) {
continue;
}
for (final fixture in blocked) {
if (content.contains(fixture)) {
violations.add('$path contains $fixture');
}
}
}
}
expect(violations, isEmpty);
});
test(
'empty environment override keeps thread workspaces out of real HOME',
() async {
@ -19,19 +123,21 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession('unit-fixture-task-a');
expect(controller.userHomeDirectory, isNot(isEmpty));
if (realHome.isNotEmpty) {
expect(controller.userHomeDirectory, isNot(realHome));
}
expect(
controller.localThreadWorkspacePathInternal('draft:unit-task-a'),
isNot(contains('$realHome/.xworkmate/threads/draft-unit-task-a')),
controller.localThreadWorkspacePathInternal('unit-fixture-task-a'),
isNot(contains('$realHome/.xworkmate/threads/unit-fixture-task-a')),
);
expect(
controller.localThreadWorkspaceDisplayPathInternal('draft:unit-task-a'),
'\$HOME/.xworkmate/threads/draft-unit-task-a',
controller.localThreadWorkspaceDisplayPathInternal(
'unit-fixture-task-a',
),
'\$HOME/.xworkmate/threads/unit-fixture-task-a',
);
},
);
@ -212,9 +318,9 @@ void main() {
});
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -227,21 +333,21 @@ void main() {
expect(
assistantWorkingDirectoryForSessionRuntimeInternal(
controller,
'draft:unit-task-a',
'unit-fixture-task-a',
),
localWorkspace.path,
);
expect(
resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal(
controller,
'draft:unit-task-a',
'unit-fixture-task-a',
),
localWorkspace.path,
);
expect(
assistantRemoteWorkingDirectoryHintForSessionRuntimeInternal(
controller,
'draft:unit-task-a',
'unit-fixture-task-a',
),
remoteWorkspace.path,
);
@ -298,9 +404,9 @@ void main() {
});
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -327,20 +433,20 @@ void main() {
);
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
final artifact = File('${localWorkspace.path}/notes/hello.txt');
expect(await artifact.readAsString(), 'artifact body');
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
final versionedArtifact = File('${localWorkspace.path}/notes/hello.v2.txt');
expect(await versionedArtifact.readAsString(), 'artifact body');
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'draft:unit-task-a',
sessionKey: 'unit-fixture-task-a',
);
expect(snapshot.resultEntries.map((entry) => entry.relativePath), <String>[
'notes/hello.v2.txt',
@ -351,7 +457,7 @@ void main() {
);
expect(
controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus,
'synced',
);
@ -377,9 +483,9 @@ void main() {
await staleArtifact.writeAsString('stale task output');
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -406,12 +512,12 @@ void main() {
);
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'draft:unit-task-a',
sessionKey: 'unit-fixture-task-a',
);
final currentRelativePaths = snapshot.resultEntries
.map((entry) => entry.relativePath)
@ -432,7 +538,7 @@ void main() {
previewable: true,
workspacePath: localWorkspace.path,
),
sessionKey: 'draft:unit-task-a',
sessionKey: 'unit-fixture-task-a',
);
expect(stalePreview.kind, AssistantArtifactPreviewKind.markdown);
expect(stalePreview.content, 'stale task output');
@ -472,9 +578,9 @@ void main() {
});
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -504,7 +610,7 @@ void main() {
final clientFactory = _proxiedClientFactory(server.port);
await HttpOverrides.runZoned(() async {
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
}, createHttpClient: clientFactory);
@ -513,7 +619,7 @@ void main() {
expect(await artifact.readAsString(), 'downloaded artifact body');
expect(observedAuthorization, 'Bearer bridge-token');
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'draft:unit-task-a',
sessionKey: 'unit-fixture-task-a',
);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
@ -521,7 +627,7 @@ void main() {
);
expect(
controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus,
'synced',
);
@ -710,9 +816,9 @@ void main() {
}
});
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -730,7 +836,7 @@ void main() {
'relativePath': 'reports/resume.bin',
'downloadUrl':
'http://xworkmate-bridge.svc.plus:${server.port}/artifacts/openclaw/download'
'?sessionKey=draft:unit-task-a&runId=run-1&relativePath=reports%2Fresume.bin'
'?sessionKey=unit-fixture-task-a&runId=run-1&relativePath=reports%2Fresume.bin'
'&expires=9999999999&sig=test-signature',
'contentType': 'application/octet-stream',
'sizeBytes': body.length,
@ -746,7 +852,7 @@ void main() {
final clientFactory = _proxiedClientFactory(server.port);
await HttpOverrides.runZoned(() async {
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
}, createHttpClient: clientFactory);
@ -759,7 +865,7 @@ void main() {
);
expect(
controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus,
'synced',
);
@ -814,9 +920,9 @@ void main() {
}
});
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -846,7 +952,7 @@ void main() {
final clientFactory = _proxiedClientFactory(server.port);
await HttpOverrides.runZoned(() async {
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
}, createHttpClient: clientFactory);
@ -858,7 +964,7 @@ void main() {
);
expect(
controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus,
'synced',
);
@ -897,9 +1003,9 @@ void main() {
}
});
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -940,7 +1046,7 @@ void main() {
final clientFactory = _proxiedClientFactory(server.port);
await HttpOverrides.runZoned(() async {
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
}, createHttpClient: clientFactory);
@ -958,7 +1064,7 @@ void main() {
isFalse,
);
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'draft:unit-task-a',
sessionKey: 'unit-fixture-task-a',
);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
@ -966,7 +1072,7 @@ void main() {
);
expect(
controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus,
'partial',
);
@ -999,9 +1105,9 @@ void main() {
}
});
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -1034,7 +1140,7 @@ void main() {
final clientFactory = _proxiedClientFactory(server.port);
await HttpOverrides.runZoned(() async {
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
}, createHttpClient: clientFactory);
@ -1050,7 +1156,7 @@ void main() {
expect(leftovers, isEmpty);
expect(
controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus,
'download-failed',
);
@ -1073,9 +1179,9 @@ void main() {
}
});
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -1095,13 +1201,13 @@ void main() {
);
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
expect(await localWorkspace.list(recursive: true).toList(), isEmpty);
final thread = controller.requireTaskThreadForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts');
expect(thread.lastArtifactSyncAtMs, greaterThan(0));
@ -1125,9 +1231,9 @@ void main() {
final staleArtifact = File('${localWorkspace.path}/old-task-report.md');
await staleArtifact.writeAsString('stale task output');
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -1146,18 +1252,18 @@ void main() {
);
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
expect(
controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus,
'no-artifacts',
);
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'draft:unit-task-a',
sessionKey: 'unit-fixture-task-a',
);
expect(snapshot.resultEntries, isEmpty);
expect(
@ -1184,9 +1290,9 @@ void main() {
});
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:unit-task-a',
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
@ -1213,7 +1319,7 @@ void main() {
);
await controller.persistGoTaskArtifactsForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
result,
);
@ -1223,7 +1329,7 @@ void main() {
);
expect(
controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus,
'no-artifacts',
);
@ -1238,3 +1344,66 @@ HttpClient Function(SecurityContext?) _proxiedClientFactory(int port) {
var index = 0;
return (_) => clients[index++];
}
String _pollutedUnitSessionKey() =>
'draft'
':unit-task-a';
String _pollutedTestSessionKey() =>
'draft'
':test-task-a';
String _pollutedUnitWorkspaceName() =>
'draft'
'-unit-task-a';
String _pollutedTestWorkspaceName() =>
'draft'
'-test-task-a';
TaskThread _persistedThread({
required String sessionKey,
required String title,
required String workspacePath,
}) {
return TaskThread(
threadId: sessionKey,
title: title,
workspaceBinding: WorkspaceBinding(
workspaceId: sessionKey,
workspaceKind: WorkspaceKind.localFs,
workspacePath: workspacePath,
displayPath: workspacePath,
writable: true,
),
executionBinding: const ExecutionBinding(
executionMode: ThreadExecutionMode.gateway,
executorId: 'openclaw',
providerId: 'openclaw',
endpointId: '',
),
);
}
class _RecordingSecureConfigStore extends SecureConfigStore {
_RecordingSecureConfigStore({required String rootPath})
: super(
secretRootPathResolver: () async => '$rootPath/secrets',
appDataRootPathResolver: () async => '$rootPath/app-data',
supportRootPathResolver: () async => '$rootPath/support',
enableSecureStorage: false,
);
bool clearAssistantLocalStateCalled = false;
@override
Future<void> clearAssistantLocalState() async {
clearAssistantLocalStateCalled = true;
await super.clearAssistantLocalState();
}
}
Future<void> _waitForControllerInitialization(AppController controller) async {
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (controller.initializing && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
expect(controller.initializing, isFalse);
}

View File

@ -43,7 +43,9 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
@ -75,7 +77,9 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
@ -108,7 +112,9 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
@ -271,7 +277,7 @@ void main() {
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession('unit-fixture-task-a');
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
@ -298,7 +304,7 @@ void main() {
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession('unit-fixture-task-a');
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
@ -320,7 +326,9 @@ void main() {
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
@ -350,7 +358,9 @@ void main() {
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
@ -380,7 +390,9 @@ void main() {
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
@ -407,7 +419,7 @@ void main() {
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession('unit-fixture-task-a');
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);

View File

@ -88,13 +88,15 @@ void main() {
],
);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
expect(
controller.assistantProviderForSession('draft:unit-task-a'),
controller.assistantProviderForSession('unit-fixture-task-a'),
SingleAgentProvider.openclaw,
);
},
@ -125,7 +127,9 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
expect(controller.currentAssistantExecutionTarget.isAgent, isTrue);
expect(
@ -138,14 +142,14 @@ void main() {
);
final record = controller.requireTaskThreadForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(
record.executionBinding.executionMode,
ThreadExecutionMode.gateway,
);
expect(
controller.assistantProviderForSession('draft:unit-task-a'),
controller.assistantProviderForSession('unit-fixture-task-a'),
SingleAgentProvider.openclaw,
);
},
@ -396,17 +400,19 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
final record = controller.requireTaskThreadForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(
controller.assistantExecutionTargetForSession('draft:unit-task-a'),
controller.assistantExecutionTargetForSession('unit-fixture-task-a'),
AssistantExecutionTarget.gateway,
);
expect(record.executionBinding.providerId, isEmpty);
@ -430,13 +436,15 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
final routing = controller.buildExternalAcpRoutingForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(routing.mode, ExternalCodeAgentAcpRoutingMode.explicit);
@ -565,7 +573,9 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(controller.assistantProviderCatalog, isEmpty);
@ -627,7 +637,9 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
@ -738,7 +750,9 @@ void main() {
),
);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.agent,
);
@ -776,16 +790,18 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
expect(
controller.hasCommittedUserTurnForGatewaySessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
),
isFalse,
);
controller.appendLocalSessionMessageInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
GatewayChatMessage(
id: 'error-1',
role: 'assistant',
@ -802,13 +818,13 @@ void main() {
expect(
controller.hasCommittedUserTurnForGatewaySessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
),
isFalse,
);
controller.appendLocalSessionMessageInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
GatewayChatMessage(
id: 'assistant-1',
role: 'assistant',
@ -825,13 +841,13 @@ void main() {
expect(
controller.hasCommittedUserTurnForGatewaySessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
),
isFalse,
);
controller.appendLocalSessionMessageInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
GatewayChatMessage(
id: 'user-1',
role: 'user',
@ -848,24 +864,24 @@ void main() {
expect(
controller.hasCommittedUserTurnForGatewaySessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
),
isTrue,
);
expect(
controller.shouldResumeGatewaySessionForNextSendInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
),
isTrue,
);
controller.upsertTaskThreadInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
lastResultCode: gatewayAcpHttpConnectTimeoutCode,
);
expect(
controller.shouldResumeGatewaySessionForNextSendInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
),
isFalse,
);
@ -877,7 +893,7 @@ void main() {
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession('unit-fixture-task-a');
await controller.sendChatMessage('first turn');
@ -899,8 +915,8 @@ void main() {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..updatesBeforeNextOutcome.add(
const GoTaskServiceUpdate(
sessionId: 'draft:unit-task-a',
threadId: 'draft:unit-task-a',
sessionId: 'unit-fixture-task-a',
threadId: 'unit-fixture-task-a',
turnId: 'turn-1',
type: 'delta',
text: 'partial output that must not persist',
@ -932,7 +948,9 @@ void main() {
addTearDown(controller.dispose);
controller.resolvedUserHomeDirectoryInternal = localWorkspace.path;
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.sendChatMessage('first turn');
@ -940,7 +958,7 @@ void main() {
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
expect(
controller
.taskThreadForSessionInternal('draft:unit-task-a')
.taskThreadForSessionInternal('unit-fixture-task-a')
?.lifecycleState
.status,
'ready',
@ -955,7 +973,7 @@ void main() {
);
expect(
controller
.taskThreadForSessionInternal('draft:unit-task-a')
.taskThreadForSessionInternal('unit-fixture-task-a')
?.lastArtifactSyncStatus,
'failed',
);
@ -965,19 +983,19 @@ void main() {
expect(fakeGoTaskService.requests, hasLength(2));
expect(fakeGoTaskService.requests.last.resumeSession, isTrue);
expect(
controller.localSessionMessagesInternal['draft:unit-task-a']!.map(
controller.localSessionMessagesInternal['unit-fixture-task-a']!.map(
(message) => message.text,
),
contains('全部 6 个文件已生成 ✅'),
);
final thread = controller.taskThreadForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(thread?.lifecycleState.status, 'ready');
expect(thread?.lastArtifactSyncStatus, 'synced');
expect(thread?.lastArtifactSyncAtMs, greaterThan(0));
final workspacePath = controller.assistantWorkspacePathForSession(
'draft:unit-task-a',
'unit-fixture-task-a',
);
for (final artifact in _generatedArtifactPayloads()) {
final relativePath = artifact['relativePath']! as String;
@ -1014,14 +1032,16 @@ void main() {
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.sendChatMessage('first turn');
expect(fakeGoTaskService.requests, hasLength(1));
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
final failedThread = controller.taskThreadForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(failedThread?.lifecycleState.status, 'ready');
expect(
@ -1048,7 +1068,7 @@ void main() {
'retried from a confirmed new start',
);
final thread = controller.taskThreadForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(thread?.lifecycleState.status, 'ready');
expect(thread?.lifecycleState.lastResultCode, 'success');
@ -1071,8 +1091,8 @@ void main() {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..updatesBeforeNextOutcome.add(
const GoTaskServiceUpdate(
sessionId: 'draft:unit-task-a',
threadId: 'draft:unit-task-a',
sessionId: 'unit-fixture-task-a',
threadId: 'unit-fixture-task-a',
turnId: 'turn-1',
type: 'delta',
text: 'guard partial output must not persist',
@ -1104,7 +1124,9 @@ void main() {
addTearDown(controller.dispose);
controller.resolvedUserHomeDirectoryInternal = localWorkspace.path;
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.sendChatMessage('first turn');
await controller.sendChatMessage('follow up');
@ -1124,7 +1146,7 @@ void main() {
);
final thread = controller.taskThreadForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(thread?.lifecycleState.status, 'ready');
expect(thread?.lastArtifactSyncStatus, 'no-exported-artifacts');
@ -1148,8 +1170,8 @@ void main() {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..updatesBeforeNextOutcome.add(
const GoTaskServiceUpdate(
sessionId: 'draft:unit-task-a',
threadId: 'draft:unit-task-a',
sessionId: 'unit-fixture-task-a',
threadId: 'unit-fixture-task-a',
turnId: 'turn-1',
type: 'delta',
text: guardMessage,
@ -1181,7 +1203,9 @@ void main() {
addTearDown(controller.dispose);
controller.resolvedUserHomeDirectoryInternal = localWorkspace.path;
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.sendChatMessage('create files');
final transcript = controller.chatMessages
@ -1190,7 +1214,7 @@ void main() {
expect(transcript, isNot(contains('未检测到 OpenClaw 本轮导出的实际文件')));
expect(transcript, isNot(contains('口头下载声明')));
final thread = controller.taskThreadForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(thread?.lifecycleState.lastResultCode, 'artifact_missing');
expect(thread?.lastArtifactSyncStatus, 'no-exported-artifacts');
@ -1212,8 +1236,8 @@ void main() {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..updatesBeforeNextOutcome.add(
const GoTaskServiceUpdate(
sessionId: 'draft:unit-task-a',
threadId: 'draft:unit-task-a',
sessionId: 'unit-fixture-task-a',
threadId: 'unit-fixture-task-a',
turnId: 'turn-1',
type: 'delta',
text: 'handshake partial output must not persist',
@ -1245,14 +1269,16 @@ void main() {
addTearDown(controller.dispose);
controller.resolvedUserHomeDirectoryInternal = localWorkspace.path;
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.sendChatMessage('first turn');
expect(fakeGoTaskService.requests, hasLength(1));
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
final failedThread = controller.taskThreadForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(failedThread?.lifecycleState.status, 'ready');
expect(
@ -1276,13 +1302,13 @@ void main() {
await _waitForLastChatMessageText(controller, '全部 6 个文件已生成 ✅');
expect(controller.chatMessages.last.text, '全部 6 个文件已生成 ✅');
final thread = controller.taskThreadForSessionInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
);
expect(thread?.lifecycleState.status, 'ready');
expect(thread?.lastArtifactSyncStatus, 'synced');
expect(thread?.lastArtifactSyncAtMs, greaterThan(0));
final workspacePath = controller.assistantWorkspacePathForSession(
'draft:unit-task-a',
'unit-fixture-task-a',
);
for (final artifact in _generatedArtifactPayloads()) {
final relativePath = artifact['relativePath']! as String;
@ -1303,7 +1329,9 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
final userMessage = GatewayChatMessage(
id: 'local-user-1',
@ -1329,19 +1357,19 @@ void main() {
);
controller.appendLocalSessionMessageInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
userMessage,
persistInThreadContext: true,
);
controller.appendLocalSessionMessageInternal(
'draft:unit-task-a',
'unit-fixture-task-a',
assistantMessage,
persistInThreadContext: true,
);
controller.assistantThreadMessagesInternal['draft:unit-task-a'] =
controller.assistantThreadMessagesInternal['unit-fixture-task-a'] =
List<GatewayChatMessage>.from(
controller
.requireTaskThreadForSessionInternal('draft:unit-task-a')
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.messages,
);

View File

@ -10,7 +10,7 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession('unit-fixture-task-a');
expect(controller.resolvedAssistantModel, isNotEmpty);
expect(controller.assistantModelChoices, isEmpty);
@ -30,7 +30,9 @@ void main() {
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('draft:unit-task-a');
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);

View File

@ -575,8 +575,8 @@ void main() {
'jsonrpc': '2.0',
'method': 'session.update',
'params': <String, dynamic>{
'sessionId': 'draft:unit-task-a',
'threadId': 'draft:unit-task-a',
'sessionId': 'unit-fixture-task-a',
'threadId': 'unit-fixture-task-a',
'turnId': 'turn-1',
'type': 'status',
'event': 'completed',
@ -592,7 +592,7 @@ void main() {
'relativePath': 'exports/final.md',
'downloadUrl':
'https://xworkmate-bridge.svc.plus/artifacts/openclaw/download'
'?sessionKey=draft:unit-task-a&runId=turn-1&relativePath=exports%2Ffinal.md',
'?sessionKey=unit-fixture-task-a&runId=turn-1&relativePath=exports%2Ffinal.md',
'contentType': 'text/markdown',
'sizeBytes': 42,
},
@ -620,8 +620,8 @@ void main() {
final result = await transport.executeTask(
const GoTaskServiceRequest(
sessionId: 'draft:unit-task-a',
threadId: 'draft:unit-task-a',
sessionId: 'unit-fixture-task-a',
threadId: 'unit-fixture-task-a',
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
prompt: 'create files',
@ -660,7 +660,7 @@ void main() {
final event = jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'xworkmate.bridge.accepted',
'params': <String, dynamic>{'sessionId': 'draft:unit-task-a'},
'params': <String, dynamic>{'sessionId': 'unit-fixture-task-a'},
});
final eventBytes = utf8.encode('data: $event\n\n');
request.response.headers.set(
@ -682,8 +682,8 @@ void main() {
'id': id,
'result': <String, dynamic>{
'status': 'completed',
'sessionId': 'draft:unit-task-a',
'threadId': 'draft:unit-task-a',
'sessionId': 'unit-fixture-task-a',
'threadId': 'unit-fixture-task-a',
'task': <String, dynamic>{
'state': 'completed',
'turnId': 'turn-recovered',
@ -697,7 +697,7 @@ void main() {
'relativePath': 'exports/snapshot.md',
'downloadUrl':
'https://xworkmate-bridge.svc.plus/artifacts/openclaw/download'
'?sessionKey=draft:unit-task-a&runId=turn-recovered&relativePath=exports%2Fsnapshot.md',
'?sessionKey=unit-fixture-task-a&runId=turn-recovered&relativePath=exports%2Fsnapshot.md',
'contentType': 'text/markdown',
'sizeBytes': 64,
},
@ -723,8 +723,8 @@ void main() {
final result = await transport.executeTask(
const GoTaskServiceRequest(
sessionId: 'draft:unit-task-a',
threadId: 'draft:unit-task-a',
sessionId: 'unit-fixture-task-a',
threadId: 'unit-fixture-task-a',
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
prompt: 'create files',