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);
|
final target = assistantExecutionTargetForSession(normalizedSessionKey);
|
||||||
return switch (target) {
|
return switch (target) {
|
||||||
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
|
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
|
||||||
AssistantExecutionTarget.local ||
|
AssistantExecutionTarget.local || AssistantExecutionTarget.singleAgent =>
|
||||||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
|
_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(
|
WorkspaceRefKind _defaultWorkspaceRefKindForTarget(
|
||||||
AssistantExecutionTarget target,
|
AssistantExecutionTarget target,
|
||||||
) {
|
) {
|
||||||
@ -1305,7 +1369,13 @@ class AppController extends ChangeNotifier {
|
|||||||
final existingWorkspaceRef = existing?.workspaceRef.trim() ?? '';
|
final existingWorkspaceRef = existing?.workspaceRef.trim() ?? '';
|
||||||
if (existing != null &&
|
if (existing != null &&
|
||||||
existingWorkspaceRef.isNotEmpty &&
|
existingWorkspaceRef.isNotEmpty &&
|
||||||
existing.workspaceRefKind == nextWorkspaceRefKind) {
|
existing.workspaceRefKind == nextWorkspaceRefKind &&
|
||||||
|
!_usesLegacySharedWorkspaceRef(
|
||||||
|
normalizedSessionKey,
|
||||||
|
executionTarget: resolvedTarget,
|
||||||
|
workspaceRef: existingWorkspaceRef,
|
||||||
|
workspaceRefKind: existing.workspaceRefKind,
|
||||||
|
)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (existing != null &&
|
if (existing != null &&
|
||||||
@ -2201,11 +2271,7 @@ class AppController extends ChangeNotifier {
|
|||||||
singleAgentProvider:
|
singleAgentProvider:
|
||||||
singleAgentProvider ??
|
singleAgentProvider ??
|
||||||
singleAgentProviderForSession(currentSessionKey),
|
singleAgentProviderForSession(currentSessionKey),
|
||||||
workspaceRef: switch (resolvedTarget) {
|
workspaceRef: _defaultWorkspaceRefForSession(normalizedSessionKey),
|
||||||
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
|
|
||||||
AssistantExecutionTarget.local ||
|
|
||||||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
|
|
||||||
},
|
|
||||||
workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget),
|
workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget),
|
||||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||||
);
|
);
|
||||||
@ -3379,15 +3445,20 @@ class AppController extends ChangeNotifier {
|
|||||||
|
|
||||||
SettingsSnapshot _sanitizeFeatureFlagSettings(SettingsSnapshot snapshot) {
|
SettingsSnapshot _sanitizeFeatureFlagSettings(SettingsSnapshot snapshot) {
|
||||||
final features = featuresFor(_hostUiFeaturePlatform);
|
final features = featuresFor(_hostUiFeaturePlatform);
|
||||||
final allowedNavigation = normalizeAssistantNavigationDestinations(
|
final allowedNavigation =
|
||||||
snapshot.assistantNavigationDestinations,
|
normalizeAssistantNavigationDestinations(
|
||||||
).where((entry) {
|
snapshot.assistantNavigationDestinations,
|
||||||
final destination = entry.destination;
|
)
|
||||||
if (destination != null) {
|
.where((entry) {
|
||||||
return features.allowedDestinations.contains(destination);
|
final destination = entry.destination;
|
||||||
}
|
if (destination != null) {
|
||||||
return features.allowedDestinations.contains(WorkspaceDestination.settings);
|
return features.allowedDestinations.contains(destination);
|
||||||
}).toList(growable: false);
|
}
|
||||||
|
return features.allowedDestinations.contains(
|
||||||
|
WorkspaceDestination.settings,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
final sanitizedExecutionTarget = features.sanitizeExecutionTarget(
|
final sanitizedExecutionTarget = features.sanitizeExecutionTarget(
|
||||||
snapshot.assistantExecutionTarget,
|
snapshot.assistantExecutionTarget,
|
||||||
);
|
);
|
||||||
@ -4522,6 +4593,15 @@ class AppController extends ChangeNotifier {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final titleFromSettings = assistantCustomTaskTitle(sessionKey);
|
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(
|
final normalizedRecord = record.copyWith(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
title: titleFromSettings.isEmpty
|
title: titleFromSettings.isEmpty
|
||||||
@ -4547,10 +4627,10 @@ class AppController extends ChangeNotifier {
|
|||||||
record.executionTarget ?? settings.assistantExecutionTarget,
|
record.executionTarget ?? settings.assistantExecutionTarget,
|
||||||
)
|
)
|
||||||
: record.gatewayEntryState,
|
: record.gatewayEntryState,
|
||||||
workspaceRef: record.workspaceRef.trim().isEmpty
|
workspaceRef: shouldMigrateWorkspaceRef
|
||||||
? _defaultWorkspaceRefForSession(sessionKey)
|
? _defaultWorkspaceRefForSession(sessionKey)
|
||||||
: record.workspaceRef.trim(),
|
: record.workspaceRef.trim(),
|
||||||
workspaceRefKind: record.workspaceRef.trim().isEmpty
|
workspaceRefKind: shouldMigrateWorkspaceRef
|
||||||
? _defaultWorkspaceRefKindForTarget(
|
? _defaultWorkspaceRefKindForTarget(
|
||||||
record.executionTarget ?? settings.assistantExecutionTarget,
|
record.executionTarget ?? settings.assistantExecutionTarget,
|
||||||
)
|
)
|
||||||
@ -4824,7 +4904,10 @@ class AppController extends ChangeNotifier {
|
|||||||
gatewayEntryState ??
|
gatewayEntryState ??
|
||||||
existing?.gatewayEntryState ??
|
existing?.gatewayEntryState ??
|
||||||
_gatewayEntryStateForTarget(nextExecutionTarget),
|
_gatewayEntryStateForTarget(nextExecutionTarget),
|
||||||
workspaceRef: workspaceRef ?? existing?.workspaceRef ?? '',
|
workspaceRef:
|
||||||
|
workspaceRef ??
|
||||||
|
existing?.workspaceRef ??
|
||||||
|
_defaultWorkspaceRefForSession(normalizedSessionKey),
|
||||||
workspaceRefKind:
|
workspaceRefKind:
|
||||||
workspaceRefKind ??
|
workspaceRefKind ??
|
||||||
existing?.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 {
|
class _FakeGatewayRuntime extends GatewayRuntime {
|
||||||
|
|||||||
@ -65,10 +65,14 @@ void main() {
|
|||||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||||
);
|
);
|
||||||
await controller.switchSession(draftKey);
|
await controller.switchSession(draftKey);
|
||||||
expect(
|
final draftWorkspaceRef = controller.assistantWorkspaceRefForSession(
|
||||||
controller.assistantWorkspaceRefForSession(draftKey),
|
draftKey,
|
||||||
controller.settings.workspacePath,
|
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
draftWorkspaceRef,
|
||||||
|
startsWith('${controller.settings.workspacePath}/.xworkmate/threads/'),
|
||||||
|
);
|
||||||
|
expect(draftWorkspaceRef, isNot(controller.settings.workspacePath));
|
||||||
expect(
|
expect(
|
||||||
controller.assistantWorkspaceRefKindForSession(draftKey),
|
controller.assistantWorkspaceRefKindForSession(draftKey),
|
||||||
WorkspaceRefKind.localPath,
|
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(
|
test(
|
||||||
'AppController preserves recorded workspace refs when switching threads',
|
'AppController preserves recorded workspace refs when switching threads',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user