Isolate assistant thread working directories
This commit is contained in:
parent
5a7dbe872b
commit
68cdbe4e4b
@ -1272,11 +1272,75 @@ class AppController extends ChangeNotifier {
|
||||
final target = assistantExecutionTargetForSession(normalizedSessionKey);
|
||||
return switch (target) {
|
||||
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
|
||||
AssistantExecutionTarget.local ||
|
||||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
|
||||
AssistantExecutionTarget.local || AssistantExecutionTarget.singleAgent =>
|
||||
_defaultLocalWorkspaceRefForSession(normalizedSessionKey),
|
||||
};
|
||||
}
|
||||
|
||||
String _defaultLocalWorkspaceRefForSession(String sessionKey) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
final baseWorkspace = settings.workspacePath.trim();
|
||||
if (baseWorkspace.isEmpty || normalizedSessionKey == 'main') {
|
||||
return baseWorkspace;
|
||||
}
|
||||
final threadWorkspace =
|
||||
'${_trimTrailingPathSeparator(baseWorkspace)}/.xworkmate/threads/${_threadWorkspaceDirectoryName(normalizedSessionKey)}';
|
||||
_ensureLocalWorkspaceDirectory(threadWorkspace);
|
||||
return threadWorkspace;
|
||||
}
|
||||
|
||||
String _threadWorkspaceDirectoryName(String sessionKey) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
final sanitized = normalizedSessionKey
|
||||
.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '-')
|
||||
.replaceAll(RegExp(r'-{2,}'), '-')
|
||||
.replaceAll(RegExp(r'^[-.]+|[-.]+$'), '');
|
||||
return sanitized.isEmpty ? 'thread' : sanitized;
|
||||
}
|
||||
|
||||
String _trimTrailingPathSeparator(String path) {
|
||||
if (path.endsWith('/') && path.length > 1) {
|
||||
return path.substring(0, path.length - 1);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
void _ensureLocalWorkspaceDirectory(String path) {
|
||||
final normalizedPath = path.trim();
|
||||
if (normalizedPath.isEmpty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Directory(normalizedPath).createSync(recursive: true);
|
||||
} catch (_) {
|
||||
// Best effort only. The caller can still decide whether to use fallback behavior.
|
||||
}
|
||||
}
|
||||
|
||||
bool _usesLegacySharedWorkspaceRef(
|
||||
String sessionKey, {
|
||||
AssistantExecutionTarget? executionTarget,
|
||||
String? workspaceRef,
|
||||
WorkspaceRefKind? workspaceRefKind,
|
||||
}) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
if (normalizedSessionKey == 'main') {
|
||||
return false;
|
||||
}
|
||||
final resolvedTarget =
|
||||
executionTarget ??
|
||||
assistantExecutionTargetForSession(normalizedSessionKey);
|
||||
if (resolvedTarget == AssistantExecutionTarget.remote) {
|
||||
return false;
|
||||
}
|
||||
final normalizedRef = workspaceRef?.trim() ?? '';
|
||||
if (normalizedRef.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return workspaceRefKind == WorkspaceRefKind.localPath &&
|
||||
normalizedRef == settings.workspacePath.trim();
|
||||
}
|
||||
|
||||
WorkspaceRefKind _defaultWorkspaceRefKindForTarget(
|
||||
AssistantExecutionTarget target,
|
||||
) {
|
||||
@ -1305,7 +1369,13 @@ class AppController extends ChangeNotifier {
|
||||
final existingWorkspaceRef = existing?.workspaceRef.trim() ?? '';
|
||||
if (existing != null &&
|
||||
existingWorkspaceRef.isNotEmpty &&
|
||||
existing.workspaceRefKind == nextWorkspaceRefKind) {
|
||||
existing.workspaceRefKind == nextWorkspaceRefKind &&
|
||||
!_usesLegacySharedWorkspaceRef(
|
||||
normalizedSessionKey,
|
||||
executionTarget: resolvedTarget,
|
||||
workspaceRef: existingWorkspaceRef,
|
||||
workspaceRefKind: existing.workspaceRefKind,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (existing != null &&
|
||||
@ -2201,11 +2271,7 @@ class AppController extends ChangeNotifier {
|
||||
singleAgentProvider:
|
||||
singleAgentProvider ??
|
||||
singleAgentProviderForSession(currentSessionKey),
|
||||
workspaceRef: switch (resolvedTarget) {
|
||||
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
|
||||
AssistantExecutionTarget.local ||
|
||||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
|
||||
},
|
||||
workspaceRef: _defaultWorkspaceRefForSession(normalizedSessionKey),
|
||||
workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget),
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
@ -3379,15 +3445,20 @@ class AppController extends ChangeNotifier {
|
||||
|
||||
SettingsSnapshot _sanitizeFeatureFlagSettings(SettingsSnapshot snapshot) {
|
||||
final features = featuresFor(_hostUiFeaturePlatform);
|
||||
final allowedNavigation = normalizeAssistantNavigationDestinations(
|
||||
snapshot.assistantNavigationDestinations,
|
||||
).where((entry) {
|
||||
final destination = entry.destination;
|
||||
if (destination != null) {
|
||||
return features.allowedDestinations.contains(destination);
|
||||
}
|
||||
return features.allowedDestinations.contains(WorkspaceDestination.settings);
|
||||
}).toList(growable: false);
|
||||
final allowedNavigation =
|
||||
normalizeAssistantNavigationDestinations(
|
||||
snapshot.assistantNavigationDestinations,
|
||||
)
|
||||
.where((entry) {
|
||||
final destination = entry.destination;
|
||||
if (destination != null) {
|
||||
return features.allowedDestinations.contains(destination);
|
||||
}
|
||||
return features.allowedDestinations.contains(
|
||||
WorkspaceDestination.settings,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
final sanitizedExecutionTarget = features.sanitizeExecutionTarget(
|
||||
snapshot.assistantExecutionTarget,
|
||||
);
|
||||
@ -4522,6 +4593,15 @@ class AppController extends ChangeNotifier {
|
||||
continue;
|
||||
}
|
||||
final titleFromSettings = assistantCustomTaskTitle(sessionKey);
|
||||
final shouldMigrateWorkspaceRef =
|
||||
record.workspaceRef.trim().isEmpty ||
|
||||
_usesLegacySharedWorkspaceRef(
|
||||
sessionKey,
|
||||
executionTarget:
|
||||
record.executionTarget ?? settings.assistantExecutionTarget,
|
||||
workspaceRef: record.workspaceRef,
|
||||
workspaceRefKind: record.workspaceRefKind,
|
||||
);
|
||||
final normalizedRecord = record.copyWith(
|
||||
sessionKey: sessionKey,
|
||||
title: titleFromSettings.isEmpty
|
||||
@ -4547,10 +4627,10 @@ class AppController extends ChangeNotifier {
|
||||
record.executionTarget ?? settings.assistantExecutionTarget,
|
||||
)
|
||||
: record.gatewayEntryState,
|
||||
workspaceRef: record.workspaceRef.trim().isEmpty
|
||||
workspaceRef: shouldMigrateWorkspaceRef
|
||||
? _defaultWorkspaceRefForSession(sessionKey)
|
||||
: record.workspaceRef.trim(),
|
||||
workspaceRefKind: record.workspaceRef.trim().isEmpty
|
||||
workspaceRefKind: shouldMigrateWorkspaceRef
|
||||
? _defaultWorkspaceRefKindForTarget(
|
||||
record.executionTarget ?? settings.assistantExecutionTarget,
|
||||
)
|
||||
@ -4824,7 +4904,10 @@ class AppController extends ChangeNotifier {
|
||||
gatewayEntryState ??
|
||||
existing?.gatewayEntryState ??
|
||||
_gatewayEntryStateForTarget(nextExecutionTarget),
|
||||
workspaceRef: workspaceRef ?? existing?.workspaceRef ?? '',
|
||||
workspaceRef:
|
||||
workspaceRef ??
|
||||
existing?.workspaceRef ??
|
||||
_defaultWorkspaceRefForSession(normalizedSessionKey),
|
||||
workspaceRefKind:
|
||||
workspaceRefKind ??
|
||||
existing?.workspaceRefKind ??
|
||||
|
||||
@ -630,6 +630,88 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController uses an isolated workspace for draft Single Agent threads',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-single-agent-isolated-thread-cwd-',
|
||||
);
|
||||
final defaultWorkspace = Directory(
|
||||
'${tempDirectory.path}/default-workspace',
|
||||
);
|
||||
await defaultWorkspace.create(recursive: true);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
workspacePath: defaultWorkspace.path,
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
),
|
||||
);
|
||||
|
||||
final runner = _FakeSingleAgentRunner(
|
||||
resolvedProvider: SingleAgentProvider.codex,
|
||||
result: const SingleAgentRunResult(
|
||||
provider: SingleAgentProvider.codex,
|
||||
output: 'THREAD_OK',
|
||||
success: true,
|
||||
errorMessage: '',
|
||||
shouldFallbackToAiChat: false,
|
||||
),
|
||||
);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: runner,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
controller.initializeAssistantThreadContext(
|
||||
'draft:artifact-thread',
|
||||
title: 'Artifact Thread',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.switchSession('draft:artifact-thread');
|
||||
await controller.sendChatMessage('检查当前线程目录', thinking: 'low');
|
||||
|
||||
const expectedWorkspaceSuffix =
|
||||
'.xworkmate/threads/draft-artifact-thread';
|
||||
expect(runner.runCalls, 1);
|
||||
expect(
|
||||
runner.lastRequest?.workingDirectory,
|
||||
'${defaultWorkspace.path}/$expectedWorkspaceSuffix',
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefForSession('draft:artifact-thread'),
|
||||
'${defaultWorkspace.path}/$expectedWorkspaceSuffix',
|
||||
);
|
||||
expect(
|
||||
Directory(
|
||||
'${defaultWorkspace.path}/$expectedWorkspaceSuffix',
|
||||
).existsSync(),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
|
||||
@ -65,10 +65,14 @@ void main() {
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.switchSession(draftKey);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefForSession(draftKey),
|
||||
controller.settings.workspacePath,
|
||||
final draftWorkspaceRef = controller.assistantWorkspaceRefForSession(
|
||||
draftKey,
|
||||
);
|
||||
expect(
|
||||
draftWorkspaceRef,
|
||||
startsWith('${controller.settings.workspacePath}/.xworkmate/threads/'),
|
||||
);
|
||||
expect(draftWorkspaceRef, isNot(controller.settings.workspacePath));
|
||||
expect(
|
||||
controller.assistantWorkspaceRefKindForSession(draftKey),
|
||||
WorkspaceRefKind.localPath,
|
||||
@ -76,6 +80,67 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController migrates draft single-agent threads off the shared workspace root',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-workspace-migrate-',
|
||||
);
|
||||
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
|
||||
await workspaceRoot.create(recursive: true);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(workspacePath: workspaceRoot.path),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:artifact-thread',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 1,
|
||||
title: 'Artifact Thread',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: workspaceRoot.path,
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
]);
|
||||
|
||||
final controller = AppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (controller.initializing) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('controller did not initialize in time');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
|
||||
final migratedWorkspace = controller.assistantWorkspaceRefForSession(
|
||||
'draft:artifact-thread',
|
||||
);
|
||||
expect(
|
||||
migratedWorkspace,
|
||||
'${workspaceRoot.path}/.xworkmate/threads/draft-artifact-thread',
|
||||
);
|
||||
expect(Directory(migratedWorkspace).existsSync(), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController preserves recorded workspace refs when switching threads',
|
||||
() async {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user