refactor(batch6): split runtime suite core tests by behavior domains
This commit is contained in:
parent
74714cef8e
commit
323b77f0c6
@ -17,3 +17,12 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/runtime/single_agent_runner.dart';
|
||||
|
||||
part 'app_controller_ai_gateway_chat_suite_core.part.dart';
|
||||
part 'app_controller_ai_gateway_chat_suite_chat.part.dart';
|
||||
part 'app_controller_ai_gateway_chat_suite_single_agent.part.dart';
|
||||
part 'app_controller_ai_gateway_chat_suite_fakes.part.dart';
|
||||
part 'app_controller_ai_gateway_chat_suite_fixtures.part.dart';
|
||||
|
||||
void main() {
|
||||
_registerAppControllerAiGatewayChatSuiteChatTests();
|
||||
_registerAppControllerAiGatewayChatSuiteSingleAgentTests();
|
||||
}
|
||||
|
||||
280
test/runtime/app_controller_ai_gateway_chat_suite_chat.part.dart
Normal file
280
test/runtime/app_controller_ai_gateway_chat_suite_chat.part.dart
Normal file
@ -0,0 +1,280 @@
|
||||
part of 'app_controller_ai_gateway_chat_suite.dart';
|
||||
|
||||
void _registerAppControllerAiGatewayChatSuiteChatTests() {
|
||||
group('AI Gateway chat streaming', () {
|
||||
test(
|
||||
'AppController streams and restores persistent Single Agent conversation turns',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-ai-gateway-chat-',
|
||||
);
|
||||
final server = await _FakeAiGatewayServer.start(
|
||||
responseMode: _AiGatewayResponseMode.sse,
|
||||
);
|
||||
addTearDown(() async {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = await _createAppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: _FallbackOnlySingleAgentRunner(),
|
||||
);
|
||||
|
||||
await controller.settingsController.saveAiGatewayApiKey('live-key');
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: server.baseUrl,
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
defaultModel: 'gpt-5.4',
|
||||
multiAgent: controller.settings.multiAgent.copyWith(
|
||||
autoSync: false,
|
||||
mountTargets: _withAvailableMountTargets(
|
||||
controller.settings.multiAgent.mountTargets,
|
||||
const <String>[],
|
||||
),
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
const firstQuestion =
|
||||
'Execution context:\n'
|
||||
'- target: single-agent\n'
|
||||
'- workspace_root: /opt/data/workspace\n'
|
||||
'- permission: full-access\n\n'
|
||||
'今天聊点什么';
|
||||
const secondQuestion = '继续刚才的话题';
|
||||
|
||||
final firstTurn = controller.sendChatMessage(
|
||||
firstQuestion,
|
||||
thinking: 'low',
|
||||
);
|
||||
await _waitFor(
|
||||
() => controller.chatMessages.any(
|
||||
(message) => message.role == 'assistant' && message.pending,
|
||||
),
|
||||
);
|
||||
expect(controller.hasAssistantPendingRun, isTrue);
|
||||
server.allowCompletion(1);
|
||||
await firstTurn;
|
||||
|
||||
await _waitFor(
|
||||
() => controller.chatMessages.any(
|
||||
(message) =>
|
||||
message.role == 'assistant' && message.text == 'FIRST_REPLY',
|
||||
),
|
||||
);
|
||||
|
||||
final secondStore = _createStoreFromTempDirectory(tempDirectory);
|
||||
final secondGateway = _FakeGatewayRuntime(store: secondStore);
|
||||
final secondController = await _createAppController(
|
||||
store: secondStore,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: secondGateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: _FallbackOnlySingleAgentRunner(),
|
||||
);
|
||||
|
||||
await secondController.settingsController.saveAiGatewayApiKey(
|
||||
'live-key',
|
||||
);
|
||||
|
||||
expect(secondController.chatMessages.last.text, 'FIRST_REPLY');
|
||||
expect(
|
||||
secondController.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
final secondTurn = secondController.sendChatMessage(
|
||||
secondQuestion,
|
||||
thinking: 'low',
|
||||
);
|
||||
await _waitFor(
|
||||
() => secondController.chatMessages.any(
|
||||
(message) => message.role == 'assistant' && message.pending,
|
||||
),
|
||||
);
|
||||
server.allowCompletion(2);
|
||||
await secondTurn;
|
||||
|
||||
await _waitFor(
|
||||
() => secondController.chatMessages.any(
|
||||
(message) =>
|
||||
message.role == 'assistant' && message.text == 'SECOND_REPLY',
|
||||
),
|
||||
);
|
||||
|
||||
expect(server.requestCount, 2);
|
||||
expect(server.lastAuthorization, 'Bearer live-key');
|
||||
expect(server.requests.first['model'], 'qwen2.5-coder:latest');
|
||||
expect(server.requests.first['stream'], isTrue);
|
||||
expect(server.requests.first['messages'], <Map<String, dynamic>>[
|
||||
<String, dynamic>{'role': 'user', 'content': firstQuestion},
|
||||
]);
|
||||
expect(server.requests.last['messages'], <Map<String, dynamic>>[
|
||||
<String, dynamic>{'role': 'user', 'content': firstQuestion},
|
||||
<String, dynamic>{'role': 'assistant', 'content': 'FIRST_REPLY'},
|
||||
<String, dynamic>{'role': 'user', 'content': secondQuestion},
|
||||
]);
|
||||
expect(
|
||||
secondController.connection.status,
|
||||
RuntimeConnectionStatus.offline,
|
||||
);
|
||||
expect(secondController.assistantConnectionStatusLabel, '单机智能体');
|
||||
expect(
|
||||
secondController.assistantConnectionTargetLabel,
|
||||
'AI Chat fallback · qwen2.5-coder:latest · 127.0.0.1:${server.port}',
|
||||
);
|
||||
expect(secondController.chatMessages.last.text, 'SECOND_REPLY');
|
||||
expect(gateway.connectedProfiles, isEmpty);
|
||||
expect(secondGateway.connectedProfiles, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('AppController falls back when LLM API ignores stream mode', () async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-ai-gateway-json-fallback-',
|
||||
);
|
||||
final server = await _FakeAiGatewayServer.start(
|
||||
responseMode: _AiGatewayResponseMode.json,
|
||||
);
|
||||
addTearDown(() async {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
final controller = await _createAppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: _FallbackOnlySingleAgentRunner(),
|
||||
);
|
||||
|
||||
await controller.settingsController.saveAiGatewayApiKey('live-key');
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: server.baseUrl,
|
||||
availableModels: const <String>['moonshotai/kimi-k2.5'],
|
||||
selectedModels: const <String>['moonshotai/kimi-k2.5'],
|
||||
),
|
||||
defaultModel: 'moonshotai/kimi-k2.5',
|
||||
multiAgent: controller.settings.multiAgent.copyWith(
|
||||
autoSync: false,
|
||||
mountTargets: _withAvailableMountTargets(
|
||||
controller.settings.multiAgent.mountTargets,
|
||||
const <String>[],
|
||||
),
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
await controller.sendChatMessage('你好', thinking: 'low');
|
||||
|
||||
await _waitFor(
|
||||
() => controller.chatMessages.any(
|
||||
(message) =>
|
||||
message.role == 'assistant' && message.text == 'FIRST_REPLY',
|
||||
),
|
||||
);
|
||||
|
||||
expect(server.requests.single['stream'], isTrue);
|
||||
expect(controller.chatMessages.last.pending, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'AppController abortRun stops Single Agent streaming requests',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-ai-gateway-abort-',
|
||||
);
|
||||
final server = await _FakeAiGatewayServer.start(
|
||||
responseMode: _AiGatewayResponseMode.sse,
|
||||
);
|
||||
addTearDown(() async {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
final controller = await _createAppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: _FallbackOnlySingleAgentRunner(),
|
||||
);
|
||||
|
||||
await controller.settingsController.saveAiGatewayApiKey('live-key');
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: server.baseUrl,
|
||||
availableModels: const <String>['z-ai/glm5'],
|
||||
selectedModels: const <String>['z-ai/glm5'],
|
||||
),
|
||||
defaultModel: 'z-ai/glm5',
|
||||
multiAgent: controller.settings.multiAgent.copyWith(
|
||||
autoSync: false,
|
||||
mountTargets: _withAvailableMountTargets(
|
||||
controller.settings.multiAgent.mountTargets,
|
||||
const <String>[],
|
||||
),
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
final pendingTurn = controller.sendChatMessage(
|
||||
'今天聊点什么',
|
||||
thinking: 'low',
|
||||
);
|
||||
await _waitFor(
|
||||
() => controller.chatMessages.any(
|
||||
(message) => message.role == 'assistant' && message.pending,
|
||||
),
|
||||
);
|
||||
|
||||
await controller.abortRun();
|
||||
server.allowCompletion(1);
|
||||
await pendingTurn;
|
||||
await _waitFor(() => !controller.hasAssistantPendingRun);
|
||||
|
||||
expect(
|
||||
controller.chatMessages.where((message) => message.pending),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
controller.chatMessages.where((message) => message.error),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,266 @@
|
||||
part of 'app_controller_ai_gateway_chat_suite.dart';
|
||||
|
||||
class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
_FakeGatewayRuntime({required super.store})
|
||||
: super(identityStore: DeviceIdentityStore(store));
|
||||
|
||||
final List<GatewayConnectionProfile> connectedProfiles =
|
||||
<GatewayConnectionProfile>[];
|
||||
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
|
||||
|
||||
@override
|
||||
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
|
||||
|
||||
@override
|
||||
GatewayConnectionSnapshot get snapshot => _snapshot;
|
||||
|
||||
@override
|
||||
Stream<GatewayPushEvent> get events => const Stream<GatewayPushEvent>.empty();
|
||||
|
||||
@override
|
||||
Future<void> connectProfile(
|
||||
GatewayConnectionProfile profile, {
|
||||
int? profileIndex,
|
||||
String authTokenOverride = '',
|
||||
String authPasswordOverride = '',
|
||||
}) async {
|
||||
connectedProfiles.add(profile);
|
||||
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith(
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
remoteAddress: '${profile.host}:${profile.port}',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect({bool clearDesiredProfile = true}) async {
|
||||
_snapshot = _snapshot.copyWith(status: RuntimeConnectionStatus.offline);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String method, {
|
||||
Map<String, dynamic>? params,
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
switch (method) {
|
||||
case 'health':
|
||||
case 'status':
|
||||
return <String, dynamic>{'ok': true};
|
||||
case 'agents.list':
|
||||
return <String, dynamic>{'agents': const <Object>[], 'mainKey': 'main'};
|
||||
case 'sessions.list':
|
||||
return <String, dynamic>{'sessions': const <Object>[]};
|
||||
case 'chat.history':
|
||||
return <String, dynamic>{'messages': const <Object>[]};
|
||||
case 'skills.status':
|
||||
return <String, dynamic>{'skills': const <Object>[]};
|
||||
case 'channels.status':
|
||||
return <String, dynamic>{
|
||||
'channelMeta': const <Object>[],
|
||||
'channelLabels': const <String, dynamic>{},
|
||||
'channelDetailLabels': const <String, dynamic>{},
|
||||
'channelAccounts': const <String, dynamic>{},
|
||||
'channelOrder': const <Object>[],
|
||||
};
|
||||
case 'models.list':
|
||||
return <String, dynamic>{'models': const <Object>[]};
|
||||
case 'cron.list':
|
||||
return <String, dynamic>{'jobs': const <Object>[]};
|
||||
case 'device.pair.list':
|
||||
return <String, dynamic>{
|
||||
'pending': const <Object>[],
|
||||
'paired': const <Object>[],
|
||||
};
|
||||
case 'system-presence':
|
||||
return const <Object>[];
|
||||
default:
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeCodexRuntime extends CodexRuntime {
|
||||
@override
|
||||
Future<String?> findCodexBinary() async => null;
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
}
|
||||
|
||||
class _FakeSingleAgentRunner implements SingleAgentRunner {
|
||||
_FakeSingleAgentRunner({
|
||||
required this.resolvedProvider,
|
||||
this.result,
|
||||
this.fallbackReason,
|
||||
});
|
||||
|
||||
final SingleAgentProvider? resolvedProvider;
|
||||
final SingleAgentRunResult? result;
|
||||
final String? fallbackReason;
|
||||
|
||||
int resolveCalls = 0;
|
||||
int runCalls = 0;
|
||||
int abortCalls = 0;
|
||||
SingleAgentRunRequest? lastRequest;
|
||||
final List<SingleAgentRunRequest> requests = <SingleAgentRunRequest>[];
|
||||
|
||||
@override
|
||||
Future<SingleAgentProviderResolution> resolveProvider({
|
||||
required SingleAgentProvider selection,
|
||||
required List<SingleAgentProvider> availableProviders,
|
||||
required String configuredCodexCliPath,
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
resolveCalls += 1;
|
||||
return SingleAgentProviderResolution(
|
||||
selection: selection,
|
||||
resolvedProvider: resolvedProvider,
|
||||
fallbackReason: fallbackReason,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SingleAgentRunResult> run(SingleAgentRunRequest request) async {
|
||||
runCalls += 1;
|
||||
lastRequest = request;
|
||||
requests.add(request);
|
||||
if (result?.output.isNotEmpty == true) {
|
||||
request.onOutput?.call(result!.output);
|
||||
}
|
||||
return result ??
|
||||
SingleAgentRunResult(
|
||||
provider: request.provider,
|
||||
output: '',
|
||||
success: false,
|
||||
errorMessage: 'no result configured',
|
||||
shouldFallbackToAiChat: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> abort(String sessionId) async {
|
||||
abortCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class _FallbackOnlySingleAgentRunner extends _FakeSingleAgentRunner {
|
||||
_FallbackOnlySingleAgentRunner()
|
||||
: super(
|
||||
resolvedProvider: null,
|
||||
fallbackReason: 'No supported external CLI provider is available.',
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeAiGatewayServer {
|
||||
_FakeAiGatewayServer._(this._server, this._responseMode);
|
||||
|
||||
final HttpServer _server;
|
||||
final _AiGatewayResponseMode _responseMode;
|
||||
int requestCount = 0;
|
||||
String? lastAuthorization;
|
||||
final List<Map<String, dynamic>> requests = <Map<String, dynamic>>[];
|
||||
final Map<int, Completer<void>> _completionGates = <int, Completer<void>>{};
|
||||
|
||||
int get port => _server.port;
|
||||
String get baseUrl => 'http://127.0.0.1:${_server.port}/v1';
|
||||
|
||||
static Future<_FakeAiGatewayServer> start({
|
||||
required _AiGatewayResponseMode responseMode,
|
||||
}) async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final fake = _FakeAiGatewayServer._(server, responseMode);
|
||||
unawaited(fake._serve());
|
||||
return fake;
|
||||
}
|
||||
|
||||
void allowCompletion(int requestNumber) {
|
||||
_completionGates[requestNumber]?.complete();
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _server.close(force: true);
|
||||
}
|
||||
|
||||
Future<void> _serve() async {
|
||||
await for (final request in _server) {
|
||||
final path = request.uri.path;
|
||||
if (path != '/v1/chat/completions') {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
requestCount += 1;
|
||||
lastAuthorization = request.headers.value(
|
||||
HttpHeaders.authorizationHeader,
|
||||
);
|
||||
final body = await utf8.decoder.bind(request).join();
|
||||
requests.add((jsonDecode(body) as Map).cast<String, dynamic>());
|
||||
|
||||
final reply = requestCount == 1 ? 'FIRST_REPLY' : 'SECOND_REPLY';
|
||||
if (_responseMode == _AiGatewayResponseMode.json) {
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'id': 'chatcmpl-$requestCount',
|
||||
'choices': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'index': 0,
|
||||
'message': <String, dynamic>{
|
||||
'role': 'assistant',
|
||||
'content': reply,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await request.response.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
final gate = Completer<void>();
|
||||
_completionGates[requestCount] = gate;
|
||||
request.response.bufferOutput = false;
|
||||
request.response.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
'text/event-stream; charset=utf-8',
|
||||
);
|
||||
request.response.write(
|
||||
'data: ${jsonEncode(<String, dynamic>{
|
||||
'choices': <Object>[
|
||||
<String, dynamic>{
|
||||
'delta': <String, dynamic>{'content': '${reply.split('_').first}_'},
|
||||
},
|
||||
],
|
||||
})}\n\n',
|
||||
);
|
||||
await request.response.flush();
|
||||
await gate.future;
|
||||
try {
|
||||
request.response.write(
|
||||
'data: ${jsonEncode(<String, dynamic>{
|
||||
'choices': <Object>[
|
||||
<String, dynamic>{
|
||||
'delta': <String, dynamic>{'content': 'REPLY'},
|
||||
},
|
||||
],
|
||||
})}\n\n',
|
||||
);
|
||||
request.response.write('data: [DONE]\n\n');
|
||||
} on HttpException {
|
||||
// Client aborted the stream; allow the handler to terminate cleanly.
|
||||
}
|
||||
try {
|
||||
await request.response.close();
|
||||
} on HttpException {
|
||||
// Client closed the connection while the server was still streaming.
|
||||
} on SocketException {
|
||||
// Same as above on some runners.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _AiGatewayResponseMode { json, sse }
|
||||
@ -0,0 +1,91 @@
|
||||
part of 'app_controller_ai_gateway_chat_suite.dart';
|
||||
|
||||
Future<AppController> _createAppController({
|
||||
required SecureConfigStore store,
|
||||
List<SingleAgentProvider> availableSingleAgentProvidersOverride =
|
||||
const <SingleAgentProvider>[],
|
||||
RuntimeCoordinator? runtimeCoordinator,
|
||||
SingleAgentRunner? singleAgentRunner,
|
||||
}) async {
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride:
|
||||
availableSingleAgentProvidersOverride,
|
||||
runtimeCoordinator: runtimeCoordinator,
|
||||
singleAgentRunner: singleAgentRunner,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
return controller;
|
||||
}
|
||||
|
||||
Future<Directory> _createTempDirectory(String prefix) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(prefix);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
}
|
||||
});
|
||||
return tempDirectory;
|
||||
}
|
||||
|
||||
SecureConfigStore _createStoreFromTempDirectory(
|
||||
Directory tempDirectory, {
|
||||
String databaseFileName = 'settings.db',
|
||||
bool enableSecureStorage = false,
|
||||
Future<String> Function()? defaultSupportDirectoryPathResolver,
|
||||
}) {
|
||||
return SecureConfigStore(
|
||||
enableSecureStorage: enableSecureStorage,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/$databaseFileName',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: defaultSupportDirectoryPathResolver,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteDirectoryWithRetry(Directory directory) async {
|
||||
for (var attempt = 0; attempt < 5; attempt += 1) {
|
||||
if (!await directory.exists()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await directory.delete(recursive: true);
|
||||
return;
|
||||
} on FileSystemException {
|
||||
if (attempt == 4) {
|
||||
rethrow;
|
||||
}
|
||||
await Future<void>.delayed(Duration(milliseconds: 80 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<ManagedMountTargetState> _withAvailableMountTargets(
|
||||
List<ManagedMountTargetState> current,
|
||||
List<String> availableIds,
|
||||
) {
|
||||
final nextIds = availableIds.toSet();
|
||||
return current
|
||||
.map(
|
||||
(item) => item.copyWith(
|
||||
available: nextIds.contains(item.targetId),
|
||||
discoveryState: nextIds.contains(item.targetId) ? 'ready' : 'idle',
|
||||
syncState: nextIds.contains(item.targetId) ? 'ready' : 'idle',
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _waitFor(
|
||||
bool Function() predicate, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (!predicate()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('condition not met before timeout');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,481 @@
|
||||
part of 'app_controller_ai_gateway_chat_suite.dart';
|
||||
|
||||
void _registerAppControllerAiGatewayChatSuiteSingleAgentTests() {
|
||||
group('Single Agent provider resolution', () {
|
||||
test(
|
||||
'AppController uses the selected Single Agent provider before AI Chat fallback',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-single-agent-provider-',
|
||||
);
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
final runner = _FakeSingleAgentRunner(
|
||||
resolvedProvider: SingleAgentProvider.opencode,
|
||||
result: const SingleAgentRunResult(
|
||||
provider: SingleAgentProvider.opencode,
|
||||
output: 'CODEX_REPLY',
|
||||
success: true,
|
||||
errorMessage: '',
|
||||
shouldFallbackToAiChat: false,
|
||||
resolvedModel: 'codex-sonnet',
|
||||
),
|
||||
);
|
||||
final controller = await _createAppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: runner,
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
|
||||
|
||||
await controller.sendChatMessage('请输出 CODEX_REPLY', thinking: 'low');
|
||||
|
||||
expect(runner.resolveCalls, 1);
|
||||
expect(runner.runCalls, 1);
|
||||
expect(runner.lastRequest?.provider, SingleAgentProvider.opencode);
|
||||
expect(runner.lastRequest?.model, isEmpty);
|
||||
expect(controller.currentSingleAgentModelDisplayLabel, 'codex-sonnet');
|
||||
expect(
|
||||
controller.chatMessages.any(
|
||||
(message) =>
|
||||
message.role == 'assistant' && message.text == 'CODEX_REPLY',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
controller.chatMessages.any(
|
||||
(message) =>
|
||||
message.text.contains('单机智能体已切换到') ||
|
||||
message.text.contains('Single Agent is using'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
controller.chatMessages.any(
|
||||
(message) => message.toolName == 'OpenCode',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController shows Single Agent runtime status only when debug runtime is enabled',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-single-agent-provider-debug-',
|
||||
);
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
final runner = _FakeSingleAgentRunner(
|
||||
resolvedProvider: SingleAgentProvider.opencode,
|
||||
result: const SingleAgentRunResult(
|
||||
provider: SingleAgentProvider.opencode,
|
||||
output: 'CODEX_REPLY',
|
||||
success: true,
|
||||
errorMessage: '',
|
||||
shouldFallbackToAiChat: false,
|
||||
resolvedModel: 'codex-sonnet',
|
||||
),
|
||||
);
|
||||
final controller = await _createAppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: runner,
|
||||
);
|
||||
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(experimentalDebug: true),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
|
||||
|
||||
await controller.sendChatMessage('请输出 CODEX_REPLY', thinking: 'low');
|
||||
|
||||
expect(
|
||||
controller.chatMessages.any(
|
||||
(message) =>
|
||||
message.toolName == 'OpenCode' &&
|
||||
(message.text.contains('单机智能体已切换到') ||
|
||||
message.text.contains('Single Agent is using')),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController keeps the thread provider strict when another external CLI is available',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-single-agent-strict-provider-',
|
||||
);
|
||||
final server = await _FakeAiGatewayServer.start(
|
||||
responseMode: _AiGatewayResponseMode.json,
|
||||
);
|
||||
addTearDown(() async {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
final runner = _FakeSingleAgentRunner(
|
||||
resolvedProvider: null,
|
||||
fallbackReason: 'Codex CLI is unavailable on this device.',
|
||||
);
|
||||
final controller = await _createAppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.claude,
|
||||
],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: runner,
|
||||
);
|
||||
|
||||
await controller.settingsController.saveAiGatewayApiKey('live-key');
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: server.baseUrl,
|
||||
availableModels: const <String>['moonshotai/kimi-k2.5'],
|
||||
selectedModels: const <String>['moonshotai/kimi-k2.5'],
|
||||
),
|
||||
defaultModel: 'moonshotai/kimi-k2.5',
|
||||
multiAgent: controller.settings.multiAgent.copyWith(
|
||||
autoSync: false,
|
||||
mountTargets: _withAvailableMountTargets(
|
||||
controller.settings.multiAgent.mountTargets,
|
||||
const <String>['claude'],
|
||||
),
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
|
||||
|
||||
await controller.sendChatMessage('你好', thinking: 'low');
|
||||
|
||||
expect(runner.resolveCalls, 1);
|
||||
expect(runner.runCalls, 0);
|
||||
expect(server.requestCount, 0);
|
||||
expect(controller.currentAssistantConnectionState.connected, isFalse);
|
||||
expect(
|
||||
controller.chatMessages.any(
|
||||
(message) => message.text.contains('可切到 Auto'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController falls back to AI Chat when no external CLI is available',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-single-agent-fallback-',
|
||||
);
|
||||
final server = await _FakeAiGatewayServer.start(
|
||||
responseMode: _AiGatewayResponseMode.json,
|
||||
);
|
||||
addTearDown(() async {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
final runner = _FakeSingleAgentRunner(
|
||||
resolvedProvider: null,
|
||||
fallbackReason: 'Codex CLI is unavailable on this device.',
|
||||
);
|
||||
final controller = await _createAppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: runner,
|
||||
);
|
||||
|
||||
await controller.settingsController.saveAiGatewayApiKey('live-key');
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: server.baseUrl,
|
||||
availableModels: const <String>['moonshotai/kimi-k2.5'],
|
||||
selectedModels: const <String>['moonshotai/kimi-k2.5'],
|
||||
),
|
||||
defaultModel: 'moonshotai/kimi-k2.5',
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
|
||||
|
||||
await controller.sendChatMessage('你好', thinking: 'low');
|
||||
|
||||
expect(runner.resolveCalls, 1);
|
||||
expect(runner.runCalls, 0);
|
||||
expect(server.requestCount, 1);
|
||||
expect(
|
||||
controller.chatMessages.any(
|
||||
(message) => message.text.contains('Codex CLI is unavailable'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
controller.chatMessages.any(
|
||||
(message) => message.toolName == 'AI Chat fallback',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
controller.chatMessages.any(
|
||||
(message) =>
|
||||
message.role == 'assistant' && message.text == 'FIRST_REPLY',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Single Agent workspace resolution', () {
|
||||
test(
|
||||
'AppController uses the recorded thread workspace for Single Agent runs',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-single-agent-thread-cwd-',
|
||||
);
|
||||
final defaultWorkspace = Directory(
|
||||
'${tempDirectory.path}/default-workspace',
|
||||
);
|
||||
final threadWorkspace = Directory(
|
||||
'${tempDirectory.path}/thread-workspace',
|
||||
);
|
||||
await defaultWorkspace.create(recursive: true);
|
||||
await threadWorkspace.create(recursive: true);
|
||||
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
workspacePath: defaultWorkspace.path,
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 1,
|
||||
title: 'Main',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: threadWorkspace.path,
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
]);
|
||||
|
||||
final runner = _FakeSingleAgentRunner(
|
||||
resolvedProvider: SingleAgentProvider.opencode,
|
||||
result: const SingleAgentRunResult(
|
||||
provider: SingleAgentProvider.opencode,
|
||||
output: 'THREAD_OK',
|
||||
success: true,
|
||||
errorMessage: '',
|
||||
shouldFallbackToAiChat: false,
|
||||
),
|
||||
);
|
||||
final controller = await _createAppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: runner,
|
||||
);
|
||||
|
||||
await controller.sendChatMessage('检查当前线程目录', thinking: 'low');
|
||||
|
||||
expect(runner.runCalls, 1);
|
||||
expect(runner.lastRequest?.workingDirectory, threadWorkspace.path);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefForSession('main'),
|
||||
threadWorkspace.path,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController uses an isolated workspace for draft Single Agent threads',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-single-agent-isolated-thread-cwd-',
|
||||
);
|
||||
final defaultWorkspace = Directory(
|
||||
'${tempDirectory.path}/default-workspace',
|
||||
);
|
||||
await defaultWorkspace.create(recursive: true);
|
||||
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
workspacePath: defaultWorkspace.path,
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
),
|
||||
);
|
||||
|
||||
final runner = _FakeSingleAgentRunner(
|
||||
resolvedProvider: SingleAgentProvider.opencode,
|
||||
result: const SingleAgentRunResult(
|
||||
provider: SingleAgentProvider.opencode,
|
||||
output: 'THREAD_OK',
|
||||
success: true,
|
||||
errorMessage: '',
|
||||
shouldFallbackToAiChat: false,
|
||||
),
|
||||
);
|
||||
final controller = await _createAppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: runner,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController adopts and reuses resolved remote single-agent thread workspaces',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-single-agent-remote-thread-cwd-',
|
||||
);
|
||||
final defaultWorkspace = Directory(
|
||||
'${tempDirectory.path}/default-workspace',
|
||||
);
|
||||
await defaultWorkspace.create(recursive: true);
|
||||
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
workspacePath: defaultWorkspace.path,
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
),
|
||||
);
|
||||
|
||||
final runner = _FakeSingleAgentRunner(
|
||||
resolvedProvider: SingleAgentProvider.opencode,
|
||||
result: const SingleAgentRunResult(
|
||||
provider: SingleAgentProvider.opencode,
|
||||
output: 'THREAD_OK',
|
||||
success: true,
|
||||
errorMessage: '',
|
||||
shouldFallbackToAiChat: false,
|
||||
resolvedWorkingDirectory:
|
||||
'/opt/data/.xworkmate/threads/draft-remote-thread',
|
||||
resolvedWorkspaceRefKind: WorkspaceRefKind.remotePath,
|
||||
),
|
||||
);
|
||||
final controller = await _createAppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: runner,
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'draft:remote-thread',
|
||||
title: 'Remote Thread',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.switchSession('draft:remote-thread');
|
||||
|
||||
await controller.sendChatMessage('第一次运行', thinking: 'low');
|
||||
expect(
|
||||
runner.requests.first.workingDirectory,
|
||||
'${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread',
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefForSession('draft:remote-thread'),
|
||||
'/opt/data/.xworkmate/threads/draft-remote-thread',
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefKindForSession('draft:remote-thread'),
|
||||
WorkspaceRefKind.remotePath,
|
||||
);
|
||||
|
||||
await controller.sendChatMessage('第二次运行', thinking: 'low');
|
||||
expect(
|
||||
runner.requests.last.workingDirectory,
|
||||
'/opt/data/.xworkmate/threads/draft-remote-thread',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -15,3 +15,11 @@ import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
part 'app_controller_execution_target_switch_suite_core.part.dart';
|
||||
part 'app_controller_execution_target_switch_suite_connection.part.dart';
|
||||
part 'app_controller_execution_target_switch_suite_thread.part.dart';
|
||||
part 'app_controller_execution_target_switch_suite_fixtures.part.dart';
|
||||
part 'app_controller_execution_target_switch_suite_fakes.part.dart';
|
||||
|
||||
void main() {
|
||||
registerExecutionTargetSwitchSuiteTests();
|
||||
}
|
||||
|
||||
@ -0,0 +1,490 @@
|
||||
part of 'app_controller_execution_target_switch_suite.dart';
|
||||
|
||||
void registerExecutionTargetSwitchConnectionTests() {
|
||||
group('AppController execution target connection switching', () {
|
||||
test(
|
||||
'AppController switches gateway connection when assistant execution target changes',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-execution-target-switch-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
_withRemoteGatewayProfile(
|
||||
controller.settings.copyWith(
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
defaultModel: 'qwen2.5-coder:latest',
|
||||
),
|
||||
controller.settings.primaryRemoteGatewayProfile.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: 'gateway.example.com',
|
||||
port: 9443,
|
||||
tls: true,
|
||||
selectedAgentId: 'assistant-main',
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
|
||||
expect(
|
||||
gateway.connectedProfiles.last,
|
||||
isA<GatewayConnectionProfile>()
|
||||
.having((item) => item.mode, 'mode', RuntimeConnectionMode.remote)
|
||||
.having((item) => item.host, 'host', 'gateway.example.com')
|
||||
.having((item) => item.port, 'port', 9443)
|
||||
.having((item) => item.tls, 'tls', isTrue)
|
||||
.having(
|
||||
(item) => item.selectedAgentId,
|
||||
'selectedAgentId',
|
||||
'assistant-main',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
controller.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.local,
|
||||
);
|
||||
final expectedLocalProfile =
|
||||
controller.settings.primaryLocalGatewayProfile;
|
||||
|
||||
expect(
|
||||
gateway.connectedProfiles.last,
|
||||
isA<GatewayConnectionProfile>()
|
||||
.having((item) => item.mode, 'mode', RuntimeConnectionMode.local)
|
||||
.having((item) => item.host, 'host', expectedLocalProfile.host)
|
||||
.having((item) => item.port, 'port', expectedLocalProfile.port)
|
||||
.having((item) => item.tls, 'tls', isFalse)
|
||||
.having(
|
||||
(item) => item.selectedAgentId,
|
||||
'selectedAgentId',
|
||||
expectedLocalProfile.selectedAgentId,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
controller.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.local,
|
||||
);
|
||||
expect(
|
||||
controller.settings.primaryRemoteGatewayProfile.host,
|
||||
'gateway.example.com',
|
||||
reason:
|
||||
'Saved remote profile should remain intact after local switch.',
|
||||
);
|
||||
expect(controller.settings.primaryRemoteGatewayProfile.port, 9443);
|
||||
expect(
|
||||
controller.settings.primaryRemoteGatewayProfile.mode,
|
||||
RuntimeConnectionMode.remote,
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(
|
||||
controller.settings.primaryRemoteGatewayProfile.host,
|
||||
'gateway.example.com',
|
||||
reason:
|
||||
'Single Agent mode should preserve the saved remote endpoint.',
|
||||
);
|
||||
expect(controller.settings.primaryRemoteGatewayProfile.port, 9443);
|
||||
expect(controller.settings.primaryRemoteGatewayProfile.tls, isTrue);
|
||||
expect(
|
||||
controller.settings.primaryRemoteGatewayProfile.mode,
|
||||
RuntimeConnectionMode.remote,
|
||||
);
|
||||
expect(gateway.disconnectCount, 1);
|
||||
expect(controller.assistantConnectionStatusLabel, '单机智能体');
|
||||
expect(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
'没有可用的外部 Agent ACP 端点,请配置 LLM API fallback。',
|
||||
);
|
||||
expect(
|
||||
gateway.connectedProfiles,
|
||||
hasLength(2),
|
||||
reason: 'Single Agent mode should not open another gateway session.',
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
|
||||
expect(
|
||||
gateway.connectedProfiles.last,
|
||||
isA<GatewayConnectionProfile>()
|
||||
.having((item) => item.mode, 'mode', RuntimeConnectionMode.remote)
|
||||
.having((item) => item.host, 'host', 'gateway.example.com')
|
||||
.having((item) => item.port, 'port', 9443)
|
||||
.having((item) => item.tls, 'tls', isTrue)
|
||||
.having(
|
||||
(item) => item.selectedAgentId,
|
||||
'selectedAgentId',
|
||||
'assistant-main',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController notifies execution target changes before connect completes',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-execution-target-notify-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
_withRemoteGatewayProfile(
|
||||
controller.settings.copyWith(
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
defaultModel: 'qwen2.5-coder:latest',
|
||||
),
|
||||
controller.settings.primaryRemoteGatewayProfile.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: 'gateway.example.com',
|
||||
port: 9443,
|
||||
tls: true,
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
int notificationCount = 0;
|
||||
controller.addListener(() {
|
||||
notificationCount += 1;
|
||||
});
|
||||
|
||||
final connectGate = Completer<void>();
|
||||
gateway.holdNextConnect(connectGate);
|
||||
|
||||
final switchFuture = controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
var completed = false;
|
||||
switchFuture.then((_) {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(notificationCount, greaterThan(0));
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
'gateway.example.com:9443',
|
||||
);
|
||||
expect(completed, isFalse);
|
||||
|
||||
connectGate.complete();
|
||||
await switchFuture;
|
||||
|
||||
expect(
|
||||
controller.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(
|
||||
gateway.connectedProfiles.last.mode,
|
||||
RuntimeConnectionMode.remote,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController applySettingsDraft syncs the active session execution target',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-apply-settings-sync-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
_withRemoteGatewayProfile(
|
||||
controller.settings.copyWith(
|
||||
assistantExecutionTarget: AssistantExecutionTarget.local,
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
defaultModel: 'qwen2.5-coder:latest',
|
||||
),
|
||||
controller.settings.primaryRemoteGatewayProfile.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: 'openclaw.svc.plus',
|
||||
port: 443,
|
||||
tls: true,
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.local,
|
||||
);
|
||||
|
||||
await controller.saveSettingsDraft(
|
||||
controller.settingsDraft.copyWith(
|
||||
assistantExecutionTarget: AssistantExecutionTarget.remote,
|
||||
),
|
||||
);
|
||||
await controller.applySettingsDraft();
|
||||
|
||||
expect(
|
||||
controller.currentAssistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(
|
||||
controller.assistantExecutionTargetForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
'openclaw.svc.plus:443',
|
||||
);
|
||||
expect(
|
||||
gateway.connectedProfiles.last.mode,
|
||||
RuntimeConnectionMode.remote,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController does not leak the local endpoint into remote thread status while reconnecting',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-execution-target-remote-fallback-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
_withLocalGatewayProfile(
|
||||
controller.settings,
|
||||
controller.settings.primaryLocalGatewayProfile.copyWith(
|
||||
mode: RuntimeConnectionMode.local,
|
||||
host: '127.0.0.1',
|
||||
port: 18789,
|
||||
tls: false,
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
final connectGate = Completer<void>();
|
||||
gateway.holdNextConnect(connectGate);
|
||||
|
||||
final switchFuture = controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(controller.assistantConnectionStatusLabel, '离线');
|
||||
expect(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
'openclaw.svc.plus:443',
|
||||
);
|
||||
|
||||
connectGate.complete();
|
||||
await switchFuture;
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController notifies singleAgent target changes before disconnect completes',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-execution-target-disconnect-notify-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
_withRemoteGatewayProfile(
|
||||
controller.settings.copyWith(
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
defaultModel: 'qwen2.5-coder:latest',
|
||||
),
|
||||
controller.settings.primaryRemoteGatewayProfile.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: 'gateway.example.com',
|
||||
port: 9443,
|
||||
tls: true,
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
|
||||
int notificationCount = 0;
|
||||
controller.addListener(() {
|
||||
notificationCount += 1;
|
||||
});
|
||||
|
||||
final disconnectGate = Completer<void>();
|
||||
gateway.holdNextDisconnect(disconnectGate);
|
||||
|
||||
final switchFuture = controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
var completed = false;
|
||||
switchFuture.then((_) {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await _waitFor(() => gateway.disconnectCount == 1);
|
||||
|
||||
expect(notificationCount, greaterThan(0));
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(controller.assistantConnectionStatusLabel, '单机智能体');
|
||||
expect(completed, isFalse);
|
||||
} finally {
|
||||
if (!disconnectGate.isCompleted) {
|
||||
disconnectGate.complete();
|
||||
}
|
||||
}
|
||||
|
||||
await switchFuture;
|
||||
|
||||
expect(
|
||||
controller.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(controller.assistantConnectionStatusLabel, '单机智能体');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,150 @@
|
||||
part of 'app_controller_execution_target_switch_suite.dart';
|
||||
|
||||
class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
_FakeGatewayRuntime({required super.store})
|
||||
: super(identityStore: DeviceIdentityStore(store));
|
||||
|
||||
final List<GatewayConnectionProfile> connectedProfiles =
|
||||
<GatewayConnectionProfile>[];
|
||||
final Set<RuntimeConnectionMode> _failingModes = <RuntimeConnectionMode>{};
|
||||
Completer<void>? _connectGate;
|
||||
Completer<void>? _disconnectGate;
|
||||
int disconnectCount = 0;
|
||||
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
|
||||
|
||||
@override
|
||||
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
|
||||
|
||||
@override
|
||||
GatewayConnectionSnapshot get snapshot => _snapshot;
|
||||
|
||||
@override
|
||||
Stream<GatewayPushEvent> get events => const Stream<GatewayPushEvent>.empty();
|
||||
|
||||
@override
|
||||
Future<void> connectProfile(
|
||||
GatewayConnectionProfile profile, {
|
||||
int? profileIndex,
|
||||
String authTokenOverride = '',
|
||||
String authPasswordOverride = '',
|
||||
}) async {
|
||||
connectedProfiles.add(profile);
|
||||
final connectGate = _connectGate;
|
||||
_connectGate = null;
|
||||
if (connectGate != null && !connectGate.isCompleted) {
|
||||
await connectGate.future;
|
||||
}
|
||||
if (_failingModes.remove(profile.mode)) {
|
||||
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode)
|
||||
.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Error',
|
||||
remoteAddress: '${profile.host}:${profile.port}',
|
||||
lastError: 'Failed to connect ${profile.mode.name}',
|
||||
);
|
||||
notifyListeners();
|
||||
throw StateError('Failed to connect ${profile.mode.name}');
|
||||
}
|
||||
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith(
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
statusText: 'Connected',
|
||||
remoteAddress: '${profile.host}:${profile.port}',
|
||||
connectAuthMode: 'none',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect({bool clearDesiredProfile = true}) async {
|
||||
disconnectCount += 1;
|
||||
final disconnectGate = _disconnectGate;
|
||||
_disconnectGate = null;
|
||||
if (disconnectGate != null && !disconnectGate.isCompleted) {
|
||||
await disconnectGate.future;
|
||||
}
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.offline,
|
||||
statusText: 'Offline',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String method, {
|
||||
Map<String, dynamic>? params,
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
switch (method) {
|
||||
case 'health':
|
||||
case 'status':
|
||||
return <String, dynamic>{'ok': true};
|
||||
case 'agents.list':
|
||||
return <String, dynamic>{'agents': const <Object>[], 'mainKey': 'main'};
|
||||
case 'sessions.list':
|
||||
return <String, dynamic>{'sessions': const <Object>[]};
|
||||
case 'chat.history':
|
||||
return <String, dynamic>{'messages': const <Object>[]};
|
||||
case 'skills.status':
|
||||
return <String, dynamic>{'skills': const <Object>[]};
|
||||
case 'channels.status':
|
||||
return <String, dynamic>{
|
||||
'channelMeta': const <Object>[],
|
||||
'channelLabels': const <String, dynamic>{},
|
||||
'channelDetailLabels': const <String, dynamic>{},
|
||||
'channelAccounts': const <String, dynamic>{},
|
||||
'channelOrder': const <Object>[],
|
||||
};
|
||||
case 'models.list':
|
||||
return <String, dynamic>{'models': const <Object>[]};
|
||||
case 'cron.list':
|
||||
return <String, dynamic>{'jobs': const <Object>[]};
|
||||
case 'device.pair.list':
|
||||
return <String, dynamic>{
|
||||
'pending': const <Object>[],
|
||||
'paired': const <Object>[],
|
||||
};
|
||||
case 'system-presence':
|
||||
return const <Object>[];
|
||||
default:
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
|
||||
void failNextConnect(RuntimeConnectionMode mode) {
|
||||
_failingModes.add(mode);
|
||||
}
|
||||
|
||||
void holdNextConnect(Completer<void> gate) {
|
||||
_connectGate = gate;
|
||||
}
|
||||
|
||||
void holdNextDisconnect(Completer<void> gate) {
|
||||
_disconnectGate = gate;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeCodexRuntime extends CodexRuntime {
|
||||
@override
|
||||
Future<String?> findCodexBinary() async => null;
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
}
|
||||
|
||||
Future<void> _deleteDirectoryWithRetry(Directory directory) async {
|
||||
if (!await directory.exists()) {
|
||||
return;
|
||||
}
|
||||
for (var attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
await directory.delete(recursive: true);
|
||||
return;
|
||||
} on FileSystemException {
|
||||
if (attempt == 2) {
|
||||
rethrow;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
part of 'app_controller_execution_target_switch_suite.dart';
|
||||
|
||||
Future<void> _waitFor(bool Function() predicate) async {
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (!predicate()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('condition not met before timeout');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSnapshot _withRemoteGatewayProfile(
|
||||
SettingsSnapshot snapshot,
|
||||
GatewayConnectionProfile profile,
|
||||
) {
|
||||
return snapshot.copyWithGatewayProfileAt(kGatewayRemoteProfileIndex, profile);
|
||||
}
|
||||
|
||||
SettingsSnapshot _withLocalGatewayProfile(
|
||||
SettingsSnapshot snapshot,
|
||||
GatewayConnectionProfile profile,
|
||||
) {
|
||||
return snapshot.copyWithGatewayProfileAt(kGatewayLocalProfileIndex, profile);
|
||||
}
|
||||
@ -0,0 +1,386 @@
|
||||
part of 'app_controller_execution_target_switch_suite.dart';
|
||||
|
||||
void registerExecutionTargetSwitchThreadTests() {
|
||||
group('AppController thread execution target state', () {
|
||||
test(
|
||||
'AppController switches runtime state when the selected thread changes',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-mode-switch-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
_withRemoteGatewayProfile(
|
||||
controller.settings.copyWith(
|
||||
assistantExecutionTarget: AssistantExecutionTarget.local,
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
),
|
||||
controller.settings.primaryRemoteGatewayProfile.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: 'gateway.example.com',
|
||||
port: 9443,
|
||||
tls: true,
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'main',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
controller.initializeAssistantThreadContext(
|
||||
'remote-thread',
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
);
|
||||
|
||||
await controller.switchSession('remote-thread');
|
||||
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(
|
||||
gateway.connectedProfiles.last.mode,
|
||||
RuntimeConnectionMode.remote,
|
||||
);
|
||||
expect(
|
||||
controller.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.local,
|
||||
reason:
|
||||
'Thread switching should not overwrite the new-thread default.',
|
||||
);
|
||||
|
||||
await controller.switchSession('main');
|
||||
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(gateway.disconnectCount, 1);
|
||||
expect(controller.assistantConnectionStatusLabel, '单机智能体');
|
||||
expect(
|
||||
controller.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.local,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController keeps the thread connection chip aligned with the selected target',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-connection-chip-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
_withRemoteGatewayProfile(
|
||||
controller.settings.copyWith(
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
defaultModel: 'qwen2.5-coder:latest',
|
||||
),
|
||||
controller.settings.primaryRemoteGatewayProfile.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: 'gateway.example.com',
|
||||
port: 9443,
|
||||
tls: true,
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.local,
|
||||
);
|
||||
expect(controller.assistantConnectionStatusLabel, '已连接');
|
||||
final expectedLocalProfile =
|
||||
controller.settings.primaryLocalGatewayProfile;
|
||||
expect(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
'${expectedLocalProfile.host}:${expectedLocalProfile.port}',
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'remote-thread',
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
gateway.failNextConnect(RuntimeConnectionMode.remote);
|
||||
|
||||
await controller.switchSession('remote-thread');
|
||||
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(controller.assistantConnectionStatusLabel, '错误');
|
||||
expect(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
'gateway.example.com:9443',
|
||||
);
|
||||
expect(
|
||||
controller.currentAssistantConnectionState.lastError,
|
||||
'Failed to connect remote',
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'main',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.switchSession('main');
|
||||
|
||||
expect(controller.assistantConnectionStatusLabel, '单机智能体');
|
||||
expect(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
'没有可用的外部 Agent ACP 端点,请配置 LLM API fallback。',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('AppController persists markdown view mode per thread', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-view-mode-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'main',
|
||||
messageViewMode: AssistantMessageViewMode.raw,
|
||||
);
|
||||
controller.initializeAssistantThreadContext(
|
||||
'draft:secondary',
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
);
|
||||
|
||||
await controller.switchSession('main');
|
||||
expect(
|
||||
controller.currentAssistantMessageViewMode,
|
||||
AssistantMessageViewMode.raw,
|
||||
);
|
||||
|
||||
await controller.switchSession('draft:secondary');
|
||||
expect(
|
||||
controller.currentAssistantMessageViewMode,
|
||||
AssistantMessageViewMode.rendered,
|
||||
);
|
||||
|
||||
await controller.setAssistantMessageViewMode(
|
||||
AssistantMessageViewMode.raw,
|
||||
);
|
||||
expect(
|
||||
controller.currentAssistantMessageViewMode,
|
||||
AssistantMessageViewMode.raw,
|
||||
);
|
||||
|
||||
final reloaded = await store.loadAssistantThreadRecords();
|
||||
final secondary = reloaded.firstWhere(
|
||||
(item) => item.sessionKey == 'draft:secondary',
|
||||
);
|
||||
expect(secondary.messageViewMode, AssistantMessageViewMode.raw);
|
||||
});
|
||||
|
||||
test(
|
||||
'AppController restores the last active assistant thread across restart',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-restart-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final databasePath = '${tempDirectory.path}/settings.db';
|
||||
final firstStore = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final firstController = AppController(
|
||||
store: firstStore,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: firstStore),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(firstController.dispose);
|
||||
|
||||
await _waitFor(() => !firstController.initializing);
|
||||
firstController.initializeAssistantThreadContext(
|
||||
'draft:alpha',
|
||||
title: 'Alpha',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
firstController.initializeAssistantThreadContext(
|
||||
'draft:beta',
|
||||
title: 'Beta',
|
||||
executionTarget: AssistantExecutionTarget.local,
|
||||
);
|
||||
await firstController.saveAssistantTaskTitle('draft:beta', 'Beta Task');
|
||||
await firstController.saveAssistantTaskArchived('draft:alpha', true);
|
||||
await firstController.switchSession('draft:beta');
|
||||
|
||||
await _waitFor(
|
||||
() =>
|
||||
firstController.settings.assistantLastSessionKey == 'draft:beta',
|
||||
);
|
||||
|
||||
firstController.dispose();
|
||||
|
||||
final secondStore = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final secondController = AppController(
|
||||
store: secondStore,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: secondStore),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(secondController.dispose);
|
||||
|
||||
await _waitFor(() => !secondController.initializing);
|
||||
|
||||
expect(secondController.currentSessionKey, 'draft:beta');
|
||||
expect(secondController.settings.assistantLastSessionKey, 'draft:beta');
|
||||
expect(
|
||||
secondController.assistantCustomTaskTitle('draft:beta'),
|
||||
'Beta Task',
|
||||
);
|
||||
expect(secondController.isAssistantTaskArchived('draft:alpha'), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController clears local assistant state and resets persisted defaults',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-clear-local-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final databasePath = '${tempDirectory.path}/settings.db';
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(accountUsername: 'local-user'),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
controller.initializeAssistantThreadContext(
|
||||
'draft:clear-me',
|
||||
title: 'Clear Me',
|
||||
);
|
||||
await controller.switchSession('draft:clear-me');
|
||||
|
||||
await controller.clearAssistantLocalState();
|
||||
|
||||
expect(controller.currentSessionKey, 'main');
|
||||
expect(
|
||||
controller.settings.accountUsername,
|
||||
SettingsSnapshot.defaults().accountUsername,
|
||||
);
|
||||
expect(controller.settings.assistantLastSessionKey, isEmpty);
|
||||
expect(controller.assistantCustomTaskTitle('draft:clear-me'), isEmpty);
|
||||
|
||||
final reloadedStore = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final reloadedSnapshot = await reloadedStore.loadSettingsSnapshot();
|
||||
final reloadedThreads = await reloadedStore
|
||||
.loadAssistantThreadRecords();
|
||||
|
||||
expect(
|
||||
reloadedSnapshot.accountUsername,
|
||||
SettingsSnapshot.defaults().accountUsername,
|
||||
);
|
||||
expect(reloadedSnapshot.assistantLastSessionKey, isEmpty);
|
||||
expect(reloadedThreads, isEmpty);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -13,3 +13,13 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/runtime/skill_directory_access.dart';
|
||||
|
||||
part 'app_controller_thread_skills_suite_core.part.dart';
|
||||
part 'app_controller_thread_skills_suite_shared_roots.part.dart';
|
||||
part 'app_controller_thread_skills_suite_thread_isolation.part.dart';
|
||||
part 'app_controller_thread_skills_suite_workspace_fallback.part.dart';
|
||||
part 'app_controller_thread_skills_suite_acp.part.dart';
|
||||
part 'app_controller_thread_skills_suite_fixtures.part.dart';
|
||||
part 'app_controller_thread_skills_suite_fakes.part.dart';
|
||||
|
||||
void main() {
|
||||
registerThreadSkillsSuiteTests();
|
||||
}
|
||||
|
||||
304
test/runtime/app_controller_thread_skills_suite_acp.part.dart
Normal file
304
test/runtime/app_controller_thread_skills_suite_acp.part.dart
Normal file
@ -0,0 +1,304 @@
|
||||
part of 'app_controller_thread_skills_suite.dart';
|
||||
|
||||
void registerThreadSkillsAcpTests() {
|
||||
group('AppController ACP skill refresh and empty-root handling', () {
|
||||
test(
|
||||
'AppController merges ACP skills after shared roots and workspace skills',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-acp-skill-merge-',
|
||||
);
|
||||
final acpServer = await _AcpSkillsStatusServer.start(
|
||||
skills: const <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'skillKey': 'acp-shared',
|
||||
'name': 'Shared Skill',
|
||||
'description': 'ACP should not override shared',
|
||||
'source': 'acp',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'skillKey': 'acp-workspace',
|
||||
'name': 'Workspace Skill',
|
||||
'description': 'ACP should not override workspace',
|
||||
'source': 'acp',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'skillKey': 'acp-only',
|
||||
'name': 'ACP Only',
|
||||
'description': 'Only from ACP',
|
||||
'source': 'acp',
|
||||
},
|
||||
],
|
||||
);
|
||||
addTearDown(acpServer.close);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
final customRoot = Directory(
|
||||
'${tempDirectory.path}/custom-shared-skills',
|
||||
);
|
||||
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
|
||||
await _writeSkill(
|
||||
customRoot,
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Shared root wins',
|
||||
);
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/skills'),
|
||||
'workspace-skill',
|
||||
skillName: 'Workspace Skill',
|
||||
description: 'Workspace wins',
|
||||
);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(
|
||||
workspacePath: tempDirectory.path,
|
||||
gatewayPort: acpServer.port,
|
||||
),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 1,
|
||||
title: '',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: workspaceRoot.path,
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
]);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: <String>[customRoot.path],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.any((item) => item.label == 'ACP Only'),
|
||||
);
|
||||
|
||||
final importedSkills = controller.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
expect(
|
||||
importedSkills.map((item) => item.label),
|
||||
containsAll(const <String>[
|
||||
'Shared Skill',
|
||||
'Workspace Skill',
|
||||
'ACP Only',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
importedSkills.firstWhere((item) => item.label == 'Shared Skill'),
|
||||
isA<AssistantThreadSkillEntry>()
|
||||
.having(
|
||||
(item) => item.description,
|
||||
'description',
|
||||
'Shared root wins',
|
||||
)
|
||||
.having((item) => item.source, 'source', 'custom'),
|
||||
);
|
||||
expect(
|
||||
importedSkills.firstWhere((item) => item.label == 'Workspace Skill'),
|
||||
isA<AssistantThreadSkillEntry>()
|
||||
.having(
|
||||
(item) => item.description,
|
||||
'description',
|
||||
'Workspace wins',
|
||||
)
|
||||
.having((item) => item.source, 'source', 'workspace'),
|
||||
);
|
||||
expect(
|
||||
importedSkills.firstWhere((item) => item.label == 'ACP Only'),
|
||||
isA<AssistantThreadSkillEntry>()
|
||||
.having(
|
||||
(item) => item.description,
|
||||
'description',
|
||||
'Only from ACP',
|
||||
)
|
||||
.having((item) => item.source, 'source', 'acp'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController clears stale ACP-only skills when ACP refresh fails',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-acp-skill-error-',
|
||||
);
|
||||
final acpServer = await _AcpSkillsStatusServer.start(
|
||||
skills: const <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'skillKey': 'acp-only',
|
||||
'name': 'ACP Only',
|
||||
'description': 'Only from ACP',
|
||||
'source': 'acp',
|
||||
},
|
||||
],
|
||||
);
|
||||
addTearDown(acpServer.close);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
final customRoot = Directory(
|
||||
'${tempDirectory.path}/custom-shared-skills',
|
||||
);
|
||||
await _writeSkill(
|
||||
customRoot,
|
||||
'local-only',
|
||||
skillName: 'Local Only',
|
||||
description: 'Only from local scan',
|
||||
);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(
|
||||
workspacePath: tempDirectory.path,
|
||||
gatewayPort: acpServer.port,
|
||||
),
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: <String>[customRoot.path],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.any((item) => item.label == 'ACP Only'),
|
||||
);
|
||||
|
||||
acpServer.skillsError = <String, dynamic>{
|
||||
'code': -32001,
|
||||
'message': 'skills refresh failed',
|
||||
};
|
||||
await controller.refreshSingleAgentSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
|
||||
await _waitFor(() {
|
||||
final labels = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.map((item) => item.label)
|
||||
.toList(growable: false);
|
||||
return labels.length == 1 && labels.first == 'Local Only';
|
||||
});
|
||||
|
||||
final importedSkills = controller.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
expect(importedSkills.map((item) => item.label), const <String>[
|
||||
'Local Only',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController can return empty skills when neither public nor repo-local roots exist',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-empty-relative-skills-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(
|
||||
workspacePath: '${tempDirectory.path}/missing-workspace',
|
||||
),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 1,
|
||||
title: '',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: '${tempDirectory.path}/missing-workspace',
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
]);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: const <String>[],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.isEmpty,
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
146
test/runtime/app_controller_thread_skills_suite_fakes.part.dart
Normal file
146
test/runtime/app_controller_thread_skills_suite_fakes.part.dart
Normal file
@ -0,0 +1,146 @@
|
||||
part of 'app_controller_thread_skills_suite.dart';
|
||||
|
||||
class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService {
|
||||
_FakeSkillDirectoryAccessService({required this.userHomeDirectory});
|
||||
|
||||
final String userHomeDirectory;
|
||||
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<String> resolveUserHomeDirectory() async {
|
||||
return userHomeDirectory;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AuthorizedSkillDirectory>> authorizeDirectories({
|
||||
List<String> suggestedPaths = const <String>[],
|
||||
}) async {
|
||||
return const <AuthorizedSkillDirectory>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthorizedSkillDirectory?> authorizeDirectory({
|
||||
String suggestedPath = '',
|
||||
}) async {
|
||||
final normalized = normalizeAuthorizedSkillDirectoryPath(suggestedPath);
|
||||
if (normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return AuthorizedSkillDirectory(path: normalized);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SkillDirectoryAccessHandle?> openDirectory(
|
||||
AuthorizedSkillDirectory directory,
|
||||
) async {
|
||||
final normalized = normalizeAuthorizedSkillDirectoryPath(directory.path);
|
||||
if (normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return SkillDirectoryAccessHandle(path: normalized, onClose: () async {});
|
||||
}
|
||||
}
|
||||
|
||||
class _AcpSkillsStatusServer {
|
||||
_AcpSkillsStatusServer._(this._server, {required this.skills});
|
||||
|
||||
final HttpServer _server;
|
||||
List<Map<String, dynamic>> skills;
|
||||
Map<String, dynamic>? skillsError;
|
||||
|
||||
int get port => _server.port;
|
||||
|
||||
static Future<_AcpSkillsStatusServer> start({
|
||||
required List<Map<String, dynamic>> skills,
|
||||
}) async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final fake = _AcpSkillsStatusServer._(
|
||||
server,
|
||||
skills: skills.map((item) => Map<String, dynamic>.from(item)).toList(),
|
||||
);
|
||||
unawaited(fake._listen());
|
||||
return fake;
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _server.close(force: true);
|
||||
}
|
||||
|
||||
Future<void> _listen() async {
|
||||
await for (final request in _server) {
|
||||
if (request.uri.path == '/acp/rpc' && request.method == 'POST') {
|
||||
await _handleRpc(request);
|
||||
continue;
|
||||
}
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRpc(HttpRequest request) async {
|
||||
final body = await utf8.decodeStream(request);
|
||||
final envelope = jsonDecode(body) as Map<String, dynamic>;
|
||||
final id = envelope['id'];
|
||||
final method = envelope['method']?.toString().trim() ?? '';
|
||||
|
||||
request.response.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
'text/event-stream',
|
||||
);
|
||||
request.response.headers.set(HttpHeaders.cacheControlHeader, 'no-cache');
|
||||
|
||||
switch (method) {
|
||||
case 'acp.capabilities':
|
||||
await _writeSse(request, <String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'result': <String, dynamic>{
|
||||
'singleAgent': true,
|
||||
'multiAgent': true,
|
||||
'providers': const <String>['opencode'],
|
||||
'capabilities': <String, dynamic>{
|
||||
'single_agent': true,
|
||||
'multi_agent': true,
|
||||
'providers': const <String>['opencode'],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
case 'skills.status':
|
||||
if (skillsError != null) {
|
||||
await _writeSse(request, <String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'error': skillsError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await _writeSse(request, <String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'result': <String, dynamic>{'skills': skills},
|
||||
});
|
||||
return;
|
||||
default:
|
||||
await _writeSse(request, <String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'error': <String, dynamic>{
|
||||
'code': -32601,
|
||||
'message': 'unknown method: $method',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeSse(
|
||||
HttpRequest request,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
request.response.write('data: ${jsonEncode(payload)}\n\n');
|
||||
await request.response.flush();
|
||||
await request.response.close();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
part of 'app_controller_thread_skills_suite.dart';
|
||||
|
||||
Future<void> _writeSkill(
|
||||
Directory root,
|
||||
String folderName, {
|
||||
required String description,
|
||||
required String skillName,
|
||||
}) async {
|
||||
final directory = Directory('${root.path}/$folderName');
|
||||
await directory.create(recursive: true);
|
||||
await File(
|
||||
'${directory.path}/SKILL.md',
|
||||
).writeAsString('---\nname: $skillName\ndescription: $description\n---\n');
|
||||
}
|
||||
|
||||
Future<void> _waitFor(bool Function() predicate) async {
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 20));
|
||||
while (!predicate()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('Timed out waiting for condition');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
Future<SecureConfigStore> _createStore(String rootPath) async {
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '$rootPath/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => rootPath,
|
||||
defaultSupportDirectoryPathResolver: () async => rootPath,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(workspacePath: rootPath),
|
||||
);
|
||||
return store;
|
||||
}
|
||||
|
||||
SettingsSnapshot _singleAgentTestSettings({
|
||||
required String workspacePath,
|
||||
int gatewayPort = 9,
|
||||
}) {
|
||||
final defaults = SettingsSnapshot.defaults();
|
||||
return defaults.copyWith(
|
||||
gatewayProfiles: replaceGatewayProfileAt(
|
||||
replaceGatewayProfileAt(
|
||||
defaults.gatewayProfiles,
|
||||
kGatewayLocalProfileIndex,
|
||||
defaults.primaryLocalGatewayProfile.copyWith(
|
||||
host: '127.0.0.1',
|
||||
port: gatewayPort,
|
||||
tls: false,
|
||||
),
|
||||
),
|
||||
kGatewayRemoteProfileIndex,
|
||||
defaults.primaryRemoteGatewayProfile.copyWith(
|
||||
host: '127.0.0.1',
|
||||
port: gatewayPort,
|
||||
tls: false,
|
||||
),
|
||||
),
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
workspacePath: workspacePath,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,393 @@
|
||||
part of 'app_controller_thread_skills_suite.dart';
|
||||
|
||||
void registerThreadSkillsSharedRootTests() {
|
||||
group('AppController shared skill roots and directory authorization', () {
|
||||
test(
|
||||
'AppController scans shared single-agent public roots on startup and shares them across providers',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-single-agent-shared-skills-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final systemRoot = Directory('${tempDirectory.path}/etc-skills');
|
||||
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
|
||||
final customRootA = Directory('${tempDirectory.path}/custom-skills-a');
|
||||
final customRootB = Directory('${tempDirectory.path}/custom-skills-b');
|
||||
await _writeSkill(
|
||||
systemRoot,
|
||||
'analysis',
|
||||
skillName: 'Analysis',
|
||||
description: 'System version should be overridden',
|
||||
);
|
||||
await _writeSkill(
|
||||
agentsRoot,
|
||||
'browser',
|
||||
skillName: 'Browser Automation',
|
||||
description: 'Shared browser skill',
|
||||
);
|
||||
await _writeSkill(
|
||||
customRootA,
|
||||
'ppt',
|
||||
skillName: 'PPT',
|
||||
description: 'Presentation skill',
|
||||
);
|
||||
await _writeSkill(
|
||||
customRootB,
|
||||
'analysis',
|
||||
skillName: 'Analysis',
|
||||
description: 'Custom version wins',
|
||||
);
|
||||
await _writeSkill(
|
||||
customRootB,
|
||||
'cicd-audit',
|
||||
skillName: 'CICD Audit',
|
||||
description: 'Pipeline audit skill',
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
store: await _createStore(tempDirectory.path),
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
SingleAgentProvider.claude,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: <String>[
|
||||
systemRoot.path,
|
||||
agentsRoot.path,
|
||||
customRootA.path,
|
||||
customRootB.path,
|
||||
],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
|
||||
final firstSessionKey = controller.currentSessionKey;
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(firstSessionKey)
|
||||
.map((skill) => skill.label),
|
||||
containsAll(const <String>[
|
||||
'Analysis',
|
||||
'Browser Automation',
|
||||
'PPT',
|
||||
'CICD Audit',
|
||||
]),
|
||||
);
|
||||
final analysisSkill = controller
|
||||
.assistantImportedSkillsForSession(firstSessionKey)
|
||||
.firstWhere((skill) => skill.label == 'Analysis');
|
||||
expect(analysisSkill.description, 'Custom version wins');
|
||||
expect(analysisSkill.source, 'custom');
|
||||
expect(analysisSkill.scope, 'user');
|
||||
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
firstSessionKey,
|
||||
controller
|
||||
.assistantImportedSkillsForSession(firstSessionKey)
|
||||
.firstWhere((skill) => skill.label == 'PPT')
|
||||
.key,
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(firstSessionKey)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['PPT'],
|
||||
);
|
||||
|
||||
await controller.setSingleAgentProvider(SingleAgentProvider.claude);
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(firstSessionKey)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(firstSessionKey)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['PPT'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController hot reloads authorized custom skill directories from settings.yaml',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-skill-directory-hot-reload-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
|
||||
await _writeSkill(
|
||||
agentsRoot,
|
||||
'browser',
|
||||
skillName: 'Browser',
|
||||
description: 'Browser tasks',
|
||||
);
|
||||
|
||||
final store = await _createStore(tempDirectory.path);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: const <String>[],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.where((skill) => skill.label == 'Browser'),
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
final updatedSnapshot =
|
||||
_singleAgentTestSettings(
|
||||
workspacePath: tempDirectory.path,
|
||||
).copyWith(
|
||||
authorizedSkillDirectories: <AuthorizedSkillDirectory>[
|
||||
AuthorizedSkillDirectory(path: agentsRoot.path),
|
||||
],
|
||||
);
|
||||
final settingsFile = File('${tempDirectory.path}/config/settings.yaml');
|
||||
await settingsFile.writeAsString(
|
||||
encodeYamlDocument(updatedSnapshot.toJson()),
|
||||
flush: true,
|
||||
);
|
||||
|
||||
await _waitFor(
|
||||
() => controller.authorizedSkillDirectories
|
||||
.map((item) => item.path)
|
||||
.contains(agentsRoot.path),
|
||||
);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.any((skill) => skill.label == 'Browser'),
|
||||
);
|
||||
expect(
|
||||
controller.authorizedSkillDirectories.map((item) => item.path),
|
||||
<String>[agentsRoot.path],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController scans skills inside symlinked directories under shared roots',
|
||||
() async {
|
||||
if (Platform.isWindows) {
|
||||
return;
|
||||
}
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-skill-directory-symlink-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final sharedRoot = Directory('${tempDirectory.path}/shared-root');
|
||||
final actualSkillRoot = Directory(
|
||||
'${tempDirectory.path}/actual-skills',
|
||||
);
|
||||
await sharedRoot.create(recursive: true);
|
||||
await _writeSkill(
|
||||
actualSkillRoot,
|
||||
'linked-browser',
|
||||
skillName: 'Linked Browser',
|
||||
description: 'Loaded through a symlinked directory',
|
||||
);
|
||||
await Link(
|
||||
'${sharedRoot.path}/linked-pack',
|
||||
).create(actualSkillRoot.path);
|
||||
|
||||
final controller = AppController(
|
||||
store: await _createStore(tempDirectory.path),
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: <String>[sharedRoot.path],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.any((skill) => skill.label == 'Linked Browser'),
|
||||
);
|
||||
|
||||
final linkedSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((skill) => skill.label == 'Linked Browser');
|
||||
expect(linkedSkill.description, 'Loaded through a symlinked directory');
|
||||
expect(linkedSkill.source, 'custom');
|
||||
expect(linkedSkill.sourceLabel, contains('linked-pack/linked-browser'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController resolves preset shared roots against the access service home directory',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-skill-directory-home-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final userHome = Directory('${tempDirectory.path}/real-home');
|
||||
final agentsRoot = Directory('${userHome.path}/.agents/skills');
|
||||
await _writeSkill(
|
||||
agentsRoot,
|
||||
'browser',
|
||||
skillName: 'Browser',
|
||||
description: 'Browser tasks',
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
store: await _createStore(tempDirectory.path),
|
||||
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(
|
||||
userHomeDirectory: userHome.path,
|
||||
),
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: const <String>[
|
||||
'~/.agents/skills',
|
||||
],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.any((item) => item.label == 'Browser'),
|
||||
);
|
||||
|
||||
expect(controller.userHomeDirectory, userHome.path);
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.map((item) => item.label),
|
||||
contains('Browser'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController accepts authorized single skill package paths and keeps fixed-root scanning intact',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-single-skill-package-path-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final fixedRoot = Directory('${tempDirectory.path}/fixed-root');
|
||||
final externalRepoSkill = Directory(
|
||||
'${tempDirectory.path}/ai-workflow-craft/skills/docx',
|
||||
);
|
||||
await _writeSkill(
|
||||
fixedRoot,
|
||||
'docx',
|
||||
skillName: 'docx',
|
||||
description: 'Fixed root version',
|
||||
);
|
||||
await _writeSkill(
|
||||
externalRepoSkill.parent,
|
||||
'docx',
|
||||
skillName: 'docx',
|
||||
description: 'Imported package version',
|
||||
);
|
||||
|
||||
final store = await _createStore(tempDirectory.path);
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(workspacePath: tempDirectory.path).copyWith(
|
||||
authorizedSkillDirectories: <AuthorizedSkillDirectory>[
|
||||
AuthorizedSkillDirectory(
|
||||
path: '${externalRepoSkill.path}/SKILL.md',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: <String>[fixedRoot.path],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.any((item) => item.label == 'docx'),
|
||||
);
|
||||
|
||||
final docxSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((item) => item.label == 'docx');
|
||||
expect(docxSkill.description, 'Imported package version');
|
||||
expect(docxSkill.source, 'custom');
|
||||
expect(
|
||||
controller.authorizedSkillDirectories.map((item) => item.path),
|
||||
<String>['${tempDirectory.path}/ai-workflow-craft/skills/docx'],
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
part of 'app_controller_thread_skills_suite.dart';
|
||||
|
||||
void registerThreadSkillsThreadIsolationTests() {
|
||||
group('AppController thread-bound skill isolation', () {
|
||||
test(
|
||||
'AppController keeps thread-bound skills isolated and restores them after restart',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-isolation-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
|
||||
final customRootA = Directory('${tempDirectory.path}/custom-skills-a');
|
||||
final customRootB = Directory('${tempDirectory.path}/custom-skills-b');
|
||||
await _writeSkill(
|
||||
agentsRoot,
|
||||
'browser',
|
||||
skillName: 'Browser',
|
||||
description: 'Browser tasks',
|
||||
);
|
||||
await _writeSkill(
|
||||
customRootA,
|
||||
'ppt',
|
||||
skillName: 'PPT',
|
||||
description: 'Presentation tasks',
|
||||
);
|
||||
await _writeSkill(
|
||||
customRootB,
|
||||
'wordx',
|
||||
skillName: 'WordX',
|
||||
description: 'Document tasks',
|
||||
);
|
||||
await _writeSkill(
|
||||
customRootB,
|
||||
'cicd-audit',
|
||||
skillName: 'CICD Audit',
|
||||
description: 'Pipeline tasks',
|
||||
);
|
||||
|
||||
Future<SecureConfigStore> createStore() {
|
||||
return _createStore(tempDirectory.path);
|
||||
}
|
||||
|
||||
Future<AppController> createController() async {
|
||||
return AppController(
|
||||
store: await createStore(),
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
SingleAgentProvider.claude,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: <String>[
|
||||
agentsRoot.path,
|
||||
customRootA.path,
|
||||
customRootB.path,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final controller = await createController();
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
final taskA = controller.currentSessionKey;
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
taskA,
|
||||
controller
|
||||
.assistantImportedSkillsForSession(taskA)
|
||||
.firstWhere((skill) => skill.label == 'PPT')
|
||||
.key,
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'draft:task-b',
|
||||
title: 'Task B',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
singleAgentProvider: SingleAgentProvider.claude,
|
||||
);
|
||||
await controller.switchSession('draft:task-b');
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
final taskB = controller.currentSessionKey;
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
taskB,
|
||||
controller
|
||||
.assistantImportedSkillsForSession(taskB)
|
||||
.firstWhere((skill) => skill.label == 'WordX')
|
||||
.key,
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'draft:task-c',
|
||||
title: 'Task C',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
);
|
||||
await controller.switchSession('draft:task-c');
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
final taskC = controller.currentSessionKey;
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
taskC,
|
||||
controller
|
||||
.assistantImportedSkillsForSession(taskC)
|
||||
.firstWhere((skill) => skill.label == 'Browser')
|
||||
.key,
|
||||
);
|
||||
|
||||
expect(
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(taskA)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['PPT'],
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(taskB)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['WordX'],
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(taskC)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['Browser'],
|
||||
);
|
||||
|
||||
controller.dispose();
|
||||
|
||||
final restoredController = await createController();
|
||||
addTearDown(restoredController.dispose);
|
||||
await _waitFor(() => !restoredController.initializing);
|
||||
await restoredController.switchSession(taskA);
|
||||
await _waitFor(
|
||||
() =>
|
||||
restoredController
|
||||
.assistantImportedSkillsForSession(taskA)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
restoredController
|
||||
.assistantSelectedSkillsForSession(taskA)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['PPT'],
|
||||
);
|
||||
await restoredController.switchSession(taskB);
|
||||
await _waitFor(
|
||||
() =>
|
||||
restoredController
|
||||
.assistantImportedSkillsForSession(taskB)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
restoredController
|
||||
.assistantSelectedSkillsForSession(taskB)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['WordX'],
|
||||
);
|
||||
await restoredController.switchSession(taskC);
|
||||
await _waitFor(
|
||||
() =>
|
||||
restoredController
|
||||
.assistantImportedSkillsForSession(taskC)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
restoredController
|
||||
.assistantSelectedSkillsForSession(taskC)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['Browser'],
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
part of 'app_controller_thread_skills_suite.dart';
|
||||
|
||||
void registerThreadSkillsWorkspaceFallbackTests() {
|
||||
group('AppController workspace fallback and repo-local precedence', () {
|
||||
test(
|
||||
'AppController uses thread workspaceRef for repo-local fallback',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-workspace-ref-skills-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/skills'),
|
||||
'workspace-only',
|
||||
skillName: 'Workspace Only Skill',
|
||||
description: 'Repo-local fallback',
|
||||
);
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(
|
||||
workspacePath: '${tempDirectory.path}/unused-default-workspace',
|
||||
),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 1,
|
||||
title: '',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: workspaceRoot.path,
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
]);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: const <String>[],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.any((item) => item.label == 'Workspace Only Skill'),
|
||||
);
|
||||
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.map((item) => item.label),
|
||||
contains('Workspace Only Skill'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController keeps public roots ahead of repo-local fallback and only fills missing skills',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-global-overrides-repo-local-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final customRoot = Directory(
|
||||
'${tempDirectory.path}/custom-shared-skills',
|
||||
);
|
||||
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
|
||||
await _writeSkill(
|
||||
customRoot,
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Global wins',
|
||||
);
|
||||
await _writeSkill(
|
||||
customRoot,
|
||||
'global-only',
|
||||
skillName: 'Global Only',
|
||||
description: 'Only from global',
|
||||
);
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/skills'),
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Repo-local should not override',
|
||||
);
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/skills'),
|
||||
'workspace-only',
|
||||
skillName: 'Workspace Only',
|
||||
description: 'Only from workspace',
|
||||
);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(workspacePath: tempDirectory.path),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 1,
|
||||
title: '',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: workspaceRoot.path,
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
]);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: <String>[customRoot.path],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
)
|
||||
.length ==
|
||||
3,
|
||||
);
|
||||
|
||||
final sharedSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((item) => item.label == 'Shared Skill');
|
||||
expect(sharedSkill.description, 'Global wins');
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.map((item) => item.label),
|
||||
containsAll(const <String>[
|
||||
'Shared Skill',
|
||||
'Global Only',
|
||||
'Workspace Only',
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController scans repo-local skills from workspace skills directory only',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-repo-local-order-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/skills'),
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Workspace version wins',
|
||||
);
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/.codex/skills'),
|
||||
'legacy-only',
|
||||
skillName: 'Legacy Only',
|
||||
description: 'Deprecated workspace root should be ignored',
|
||||
);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(workspacePath: tempDirectory.path),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 1,
|
||||
title: '',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: workspaceRoot.path,
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
]);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
singleAgentSharedSkillScanRootOverrides: const <String>[],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.isNotEmpty,
|
||||
);
|
||||
|
||||
final sharedSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((item) => item.label == 'Shared Skill');
|
||||
expect(sharedSkill.description, 'Workspace version wins');
|
||||
expect(sharedSkill.source, 'workspace');
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.where((item) => item.label == 'Legacy Only'),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -11,3 +11,15 @@ import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
part 'secure_config_store_suite_core.part.dart';
|
||||
part 'secure_config_store_suite_settings.part.dart';
|
||||
part 'secure_config_store_suite_secrets.part.dart';
|
||||
part 'secure_config_store_suite_compatibility.part.dart';
|
||||
part 'secure_config_store_suite_lifecycle.part.dart';
|
||||
part 'secure_config_store_suite_fixtures.part.dart';
|
||||
|
||||
void main() {
|
||||
_registerSecureConfigStoreSuiteSettingsTests();
|
||||
_registerSecureConfigStoreSuiteSecretsTests();
|
||||
_registerSecureConfigStoreSuiteCompatibilityTests();
|
||||
_registerSecureConfigStoreSuiteLifecycleTests();
|
||||
}
|
||||
|
||||
255
test/runtime/secure_config_store_suite_compatibility.part.dart
Normal file
255
test/runtime/secure_config_store_suite_compatibility.part.dart
Normal file
@ -0,0 +1,255 @@
|
||||
part of 'secure_config_store_suite.dart';
|
||||
|
||||
void _registerSecureConfigStoreSuiteCompatibilityTests() {
|
||||
group('Compatibility', () {
|
||||
test(
|
||||
'SecureConfigStore ignores legacy local-state files and keeps them untouched',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-local-state-',
|
||||
);
|
||||
final settingsFile = File(
|
||||
'${tempDirectory.path}/settings-snapshot.json',
|
||||
);
|
||||
final threadsFile = File(
|
||||
'${tempDirectory.path}/assistant-threads.json',
|
||||
);
|
||||
await settingsFile.writeAsString('{"accountUsername":"local-user"}');
|
||||
await threadsFile.writeAsString('[]');
|
||||
|
||||
final firstStore = SecureConfigStore(
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
|
||||
final loadedSnapshot = await firstStore.loadSettingsSnapshot();
|
||||
final loadedThreads = await firstStore.loadAssistantThreadRecords();
|
||||
|
||||
expect(
|
||||
loadedSnapshot.accountUsername,
|
||||
SettingsSnapshot.defaults().accountUsername,
|
||||
);
|
||||
expect(loadedThreads, isEmpty);
|
||||
expect(await settingsFile.exists(), isTrue);
|
||||
expect(await threadsFile.exists(), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore ignores legacy shared-preferences assistant state and only reads sqlite',
|
||||
() async {
|
||||
final legacySnapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'legacy-user',
|
||||
assistantLastSessionKey: 'draft:legacy-1',
|
||||
);
|
||||
const legacyRecords = <AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:legacy-1',
|
||||
title: 'Legacy thread',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.local,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
messages: <GatewayChatMessage>[
|
||||
GatewayChatMessage(
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
text: 'legacy message',
|
||||
timestampMs: 1700000001000,
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
],
|
||||
updatedAtMs: 1700000000000,
|
||||
),
|
||||
];
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
'xworkmate.settings.snapshot': legacySnapshot.toJsonString(),
|
||||
'xworkmate.assistant.threads': jsonEncode(
|
||||
legacyRecords.map((item) => item.toJson()).toList(growable: false),
|
||||
),
|
||||
});
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-legacy-migrate-',
|
||||
resetSharedPreferences: false,
|
||||
);
|
||||
final databasePath = '${tempDirectory.path}/settings.sqlite3';
|
||||
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final loadedSnapshot = await store.loadSettingsSnapshot();
|
||||
final loadedThreads = await store.loadAssistantThreadRecords();
|
||||
|
||||
expect(
|
||||
loadedSnapshot.accountUsername,
|
||||
SettingsSnapshot.defaults().accountUsername,
|
||||
);
|
||||
expect(loadedSnapshot.assistantLastSessionKey, isEmpty);
|
||||
expect(loadedThreads, isEmpty);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(
|
||||
prefs.getString('xworkmate.settings.snapshot'),
|
||||
legacySnapshot.toJsonString(),
|
||||
);
|
||||
expect(
|
||||
prefs.getString('xworkmate.assistant.threads'),
|
||||
jsonEncode(
|
||||
legacyRecords.map((item) => item.toJson()).toList(growable: false),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore ignores stray local-state files when sqlite has no assistant state',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-ignore-stray-files-',
|
||||
);
|
||||
final databasePath = '${tempDirectory.path}/settings.sqlite3';
|
||||
await File(
|
||||
'${tempDirectory.path}/settings-snapshot.json',
|
||||
).writeAsString('{"accountUsername":"locked-user"}', flush: true);
|
||||
await File(
|
||||
'${tempDirectory.path}/assistant-threads.json',
|
||||
).writeAsString('[{"sessionKey":"ignored-thread"}]', flush: true);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final loadedSnapshot = await store.loadSettingsSnapshot();
|
||||
final loadedThreads = await store.loadAssistantThreadRecords();
|
||||
|
||||
expect(
|
||||
loadedSnapshot.accountUsername,
|
||||
SettingsSnapshot.defaults().accountUsername,
|
||||
);
|
||||
expect(loadedThreads, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('SettingsSnapshot encodes and decodes assistantLastSessionKey', () {
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
assistantLastSessionKey: 'draft:session-1',
|
||||
);
|
||||
|
||||
final decoded = SettingsSnapshot.fromJsonString(snapshot.toJsonString());
|
||||
|
||||
expect(decoded.assistantLastSessionKey, 'draft:session-1');
|
||||
});
|
||||
|
||||
test('SettingsSnapshot encodes and decodes authorizedSkillDirectories', () {
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
authorizedSkillDirectories: const <AuthorizedSkillDirectory>[
|
||||
AuthorizedSkillDirectory(path: '/etc/skills'),
|
||||
AuthorizedSkillDirectory(
|
||||
path: '/Users/test/.agents/skills',
|
||||
bookmark: 'bookmark-data',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final decoded = SettingsSnapshot.fromJsonString(snapshot.toJsonString());
|
||||
|
||||
expect(
|
||||
decoded.authorizedSkillDirectories.map((item) => item.path),
|
||||
const <String>['/Users/test/.agents/skills', '/etc/skills'],
|
||||
);
|
||||
expect(
|
||||
decoded.authorizedSkillDirectories.first.bookmark,
|
||||
'bookmark-data',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'SettingsSnapshot keeps compatibility with legacy target json values',
|
||||
() {
|
||||
final decoded = SettingsSnapshot.fromJson(<String, dynamic>{
|
||||
...SettingsSnapshot.defaults().toJson(),
|
||||
'assistantExecutionTarget': 'aiGatewayOnly',
|
||||
});
|
||||
|
||||
expect(
|
||||
decoded.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AssistantThreadRecord keeps compatibility with legacy json payloads',
|
||||
() {
|
||||
final decoded = AssistantThreadRecord.fromJson(<String, dynamic>{
|
||||
'sessionKey': 'legacy-thread',
|
||||
'messages': const <Object>[],
|
||||
'updatedAtMs': 1700000000000,
|
||||
'title': 'Legacy',
|
||||
'archived': false,
|
||||
'executionTarget': 'aiGatewayOnly',
|
||||
'messageViewMode': 'rendered',
|
||||
'discoveredSkills': const <Object>[
|
||||
<String, Object?>{
|
||||
'key': '/tmp/legacy-discovered-skill',
|
||||
'label': 'Legacy Discovered Skill',
|
||||
},
|
||||
],
|
||||
'singleAgentProvider': 'gemini',
|
||||
'gatewayEntryState': 'ai-gateway-only',
|
||||
});
|
||||
|
||||
expect(decoded.executionTarget, AssistantExecutionTarget.singleAgent);
|
||||
expect(decoded.importedSkills, isEmpty);
|
||||
expect(decoded.selectedSkillKeys, isEmpty);
|
||||
expect(decoded.assistantModelId, isEmpty);
|
||||
expect(decoded.singleAgentProvider, SingleAgentProvider.gemini);
|
||||
expect(decoded.gatewayEntryState, 'single-agent');
|
||||
expect(decoded.workspaceRef, isEmpty);
|
||||
expect(decoded.workspaceRefKind, WorkspaceRefKind.localPath);
|
||||
},
|
||||
);
|
||||
|
||||
test('AssistantThreadRecord round-trips workspaceRef fields', () {
|
||||
const record = AssistantThreadRecord(
|
||||
sessionKey: 'thread-1',
|
||||
messages: <GatewayChatMessage>[],
|
||||
updatedAtMs: 1700000000000,
|
||||
title: 'Thread 1',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: 'object://thread/thread-1',
|
||||
workspaceRefKind: WorkspaceRefKind.objectStore,
|
||||
);
|
||||
|
||||
final decoded = AssistantThreadRecord.fromJson(record.toJson());
|
||||
|
||||
expect(decoded.workspaceRef, 'object://thread/thread-1');
|
||||
expect(decoded.workspaceRefKind, WorkspaceRefKind.objectStore);
|
||||
});
|
||||
|
||||
test(
|
||||
'AssistantThreadRecord infers objectStore kind from legacy workspace ref',
|
||||
() {
|
||||
final decoded = AssistantThreadRecord.fromJson(<String, dynamic>{
|
||||
'sessionKey': 'thread-legacy',
|
||||
'messages': const <Object>[],
|
||||
'updatedAtMs': 1700000000000,
|
||||
'title': 'Legacy Object Thread',
|
||||
'archived': false,
|
||||
'executionTarget': 'remote',
|
||||
'messageViewMode': 'rendered',
|
||||
'workspaceRef': 'object://thread/thread-legacy',
|
||||
});
|
||||
|
||||
expect(decoded.workspaceRefKind, WorkspaceRefKind.objectStore);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
48
test/runtime/secure_config_store_suite_fixtures.part.dart
Normal file
48
test/runtime/secure_config_store_suite_fixtures.part.dart
Normal file
@ -0,0 +1,48 @@
|
||||
part of 'secure_config_store_suite.dart';
|
||||
|
||||
Future<Directory> _createTempDirectory(
|
||||
String prefix, {
|
||||
bool resetSharedPreferences = true,
|
||||
}) async {
|
||||
if (resetSharedPreferences) {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
}
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(prefix);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
}
|
||||
});
|
||||
return tempDirectory;
|
||||
}
|
||||
|
||||
SecureConfigStore _createStoreFromTempDirectory(
|
||||
Directory tempDirectory, {
|
||||
bool enableSecureStorage = false,
|
||||
Future<String> Function()? defaultSupportDirectoryPathResolver,
|
||||
}) {
|
||||
return SecureConfigStore(
|
||||
enableSecureStorage: enableSecureStorage,
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/${SettingsStore.databaseFileName}',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: defaultSupportDirectoryPathResolver,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteDirectoryWithRetry(Directory directory) async {
|
||||
for (var attempt = 0; attempt < 5; attempt += 1) {
|
||||
if (!await directory.exists()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await directory.delete(recursive: true);
|
||||
return;
|
||||
} on FileSystemException {
|
||||
if (attempt == 4) {
|
||||
rethrow;
|
||||
}
|
||||
await Future<void>.delayed(Duration(milliseconds: 80 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
258
test/runtime/secure_config_store_suite_lifecycle.part.dart
Normal file
258
test/runtime/secure_config_store_suite_lifecycle.part.dart
Normal file
@ -0,0 +1,258 @@
|
||||
part of 'secure_config_store_suite.dart';
|
||||
|
||||
void _registerSecureConfigStoreSuiteLifecycleTests() {
|
||||
group('Assistant state lifecycle', () {
|
||||
test(
|
||||
'SecureConfigStore persists assistant thread records and archived task keys',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-assistant-threads-',
|
||||
);
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
assistantArchivedTaskKeys: const <String>['main'],
|
||||
assistantCustomTaskTitles: const <String, String>{'main': '研发任务'},
|
||||
assistantLastSessionKey: 'main',
|
||||
);
|
||||
const records = <AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
title: '研发任务',
|
||||
archived: true,
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
messageViewMode: AssistantMessageViewMode.raw,
|
||||
importedSkills: <AssistantThreadSkillEntry>[
|
||||
AssistantThreadSkillEntry(
|
||||
key: '/tmp/imported-skill',
|
||||
label: 'Imported Skill',
|
||||
description: 'confirmed import',
|
||||
sourcePath: '/tmp/imported-skill',
|
||||
sourceLabel: 'custom/imported',
|
||||
),
|
||||
],
|
||||
selectedSkillKeys: <String>['/tmp/imported-skill'],
|
||||
assistantModelId: 'gpt-5.4-mini',
|
||||
singleAgentProvider: SingleAgentProvider.claude,
|
||||
gatewayEntryState: 'single-agent',
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[
|
||||
GatewayChatMessage(
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
text: '第一条消息',
|
||||
timestampMs: 1700000000000,
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
GatewayChatMessage(
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
text: '第一条回复',
|
||||
timestampMs: 1700000001000,
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
await store.saveSettingsSnapshot(snapshot);
|
||||
await store.saveAssistantThreadRecords(records);
|
||||
|
||||
final reloadedSnapshot = await store.loadSettingsSnapshot();
|
||||
final reloadedRecords = await store.loadAssistantThreadRecords();
|
||||
|
||||
expect(reloadedSnapshot.assistantArchivedTaskKeys, const <String>[
|
||||
'main',
|
||||
]);
|
||||
expect(reloadedSnapshot.assistantLastSessionKey, 'main');
|
||||
expect(reloadedSnapshot.assistantCustomTaskTitles['main'], '研发任务');
|
||||
expect(reloadedRecords, hasLength(1));
|
||||
expect(reloadedRecords.first.sessionKey, 'main');
|
||||
expect(reloadedRecords.first.archived, isTrue);
|
||||
expect(reloadedRecords.first.title, '研发任务');
|
||||
expect(
|
||||
reloadedRecords.first.executionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(
|
||||
reloadedRecords.first.messageViewMode,
|
||||
AssistantMessageViewMode.raw,
|
||||
);
|
||||
expect(reloadedRecords.first.importedSkills, hasLength(1));
|
||||
expect(reloadedRecords.first.selectedSkillKeys, const <String>[
|
||||
'/tmp/imported-skill',
|
||||
]);
|
||||
expect(reloadedRecords.first.assistantModelId, 'gpt-5.4-mini');
|
||||
expect(
|
||||
reloadedRecords.first.singleAgentProvider,
|
||||
SingleAgentProvider.claude,
|
||||
);
|
||||
expect(reloadedRecords.first.gatewayEntryState, 'single-agent');
|
||||
expect(reloadedRecords.first.messages, hasLength(2));
|
||||
expect(reloadedRecords.first.messages.last.text, '第一条回复');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore restart keeps database state and legacy session files untouched',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-durable-restore-',
|
||||
);
|
||||
final databasePath = '${tempDirectory.path}/settings.sqlite3';
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'backup-user',
|
||||
assistantLastSessionKey: 'draft:backup-1',
|
||||
);
|
||||
const records = <AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:backup-1',
|
||||
title: '备份线程',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[
|
||||
GatewayChatMessage(
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
text: 'backup message',
|
||||
timestampMs: 1700000001000,
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
await store.saveSettingsSnapshot(snapshot);
|
||||
await store.saveAssistantThreadRecords(records);
|
||||
final settingsFile = File(
|
||||
'${tempDirectory.path}/settings-snapshot.json',
|
||||
);
|
||||
final threadsFile = File(
|
||||
'${tempDirectory.path}/assistant-threads.json',
|
||||
);
|
||||
await settingsFile.writeAsString(
|
||||
'legacy-settings-snapshot',
|
||||
flush: true,
|
||||
);
|
||||
await threadsFile.writeAsString(
|
||||
'legacy-assistant-threads',
|
||||
flush: true,
|
||||
);
|
||||
|
||||
final recoveredStore = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final recoveredSnapshot = await recoveredStore.loadSettingsSnapshot();
|
||||
final recoveredRecords = await recoveredStore
|
||||
.loadAssistantThreadRecords();
|
||||
|
||||
expect(recoveredSnapshot.accountUsername, 'backup-user');
|
||||
expect(recoveredSnapshot.assistantLastSessionKey, 'draft:backup-1');
|
||||
expect(recoveredRecords, hasLength(1));
|
||||
expect(recoveredRecords.first.sessionKey, 'draft:backup-1');
|
||||
expect(recoveredRecords.first.messages.single.text, 'backup message');
|
||||
expect(await settingsFile.readAsString(), 'legacy-settings-snapshot');
|
||||
expect(await threadsFile.readAsString(), 'legacy-assistant-threads');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore clears assistant local state without deleting secure refs',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-clear-local-',
|
||||
);
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'clear-me',
|
||||
assistantLastSessionKey: 'draft:clear-1',
|
||||
);
|
||||
const records = <AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:clear-1',
|
||||
title: '清理线程',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.local,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[],
|
||||
),
|
||||
];
|
||||
|
||||
await store.saveSettingsSnapshot(snapshot);
|
||||
await store.saveAssistantThreadRecords(records);
|
||||
await store.saveGatewayToken('token-secret');
|
||||
|
||||
await store.clearAssistantLocalState();
|
||||
|
||||
final clearedSnapshot = await store.loadSettingsSnapshot();
|
||||
final clearedRecords = await store.loadAssistantThreadRecords();
|
||||
|
||||
expect(
|
||||
clearedSnapshot.accountUsername,
|
||||
SettingsSnapshot.defaults().accountUsername,
|
||||
);
|
||||
expect(clearedSnapshot.assistantLastSessionKey, isEmpty);
|
||||
expect(clearedRecords, isEmpty);
|
||||
expect(await store.loadGatewayToken(), 'token-secret');
|
||||
expect(
|
||||
await File('${tempDirectory.path}/settings-snapshot.json').exists(),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
await File('${tempDirectory.path}/assistant-threads.json').exists(),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore dispose closes sqlite handle and allows reopening the same database path',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-dispose-',
|
||||
);
|
||||
final databasePath = '${tempDirectory.path}/settings.sqlite3';
|
||||
final firstStore = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'dispose-user',
|
||||
);
|
||||
|
||||
await firstStore.saveSettingsSnapshot(snapshot);
|
||||
firstStore.dispose();
|
||||
firstStore.dispose();
|
||||
|
||||
final secondStore = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final reloadedSnapshot = await secondStore.loadSettingsSnapshot();
|
||||
|
||||
expect(reloadedSnapshot.accountUsername, 'dispose-user');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
200
test/runtime/secure_config_store_suite_secrets.part.dart
Normal file
200
test/runtime/secure_config_store_suite_secrets.part.dart
Normal file
@ -0,0 +1,200 @@
|
||||
part of 'secure_config_store_suite.dart';
|
||||
|
||||
void _registerSecureConfigStoreSuiteSecretsTests() {
|
||||
group('Secret storage', () {
|
||||
test(
|
||||
'SecureConfigStore keeps gateway secrets isolated per profile slot',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-profiles-',
|
||||
);
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
|
||||
await store.saveGatewayToken(
|
||||
'local-token',
|
||||
profileIndex: kGatewayLocalProfileIndex,
|
||||
);
|
||||
await store.saveGatewayToken(
|
||||
'remote-token',
|
||||
profileIndex: kGatewayRemoteProfileIndex,
|
||||
);
|
||||
await store.saveGatewayPassword(
|
||||
'custom-password',
|
||||
profileIndex: kGatewayCustomProfileStartIndex,
|
||||
);
|
||||
|
||||
final secureRefs = await store.loadSecureRefs();
|
||||
|
||||
expect(
|
||||
await store.loadGatewayToken(profileIndex: kGatewayLocalProfileIndex),
|
||||
'local-token',
|
||||
);
|
||||
expect(
|
||||
await store.loadGatewayToken(
|
||||
profileIndex: kGatewayRemoteProfileIndex,
|
||||
),
|
||||
'remote-token',
|
||||
);
|
||||
expect(
|
||||
await store.loadGatewayPassword(
|
||||
profileIndex: kGatewayCustomProfileStartIndex,
|
||||
),
|
||||
'custom-password',
|
||||
);
|
||||
expect(
|
||||
secureRefs['gateway_token_$kGatewayLocalProfileIndex'],
|
||||
'local-token',
|
||||
);
|
||||
expect(
|
||||
secureRefs['gateway_token_$kGatewayRemoteProfileIndex'],
|
||||
'remote-token',
|
||||
);
|
||||
expect(
|
||||
secureRefs['gateway_password_$kGatewayCustomProfileStartIndex'],
|
||||
'custom-password',
|
||||
);
|
||||
expect(await store.loadGatewayToken(), 'remote-token');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore writes secrets into the fixed secret path',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-secret-path-',
|
||||
);
|
||||
final store = SecureConfigStore(
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
'${tempDirectory.path}/secrets',
|
||||
);
|
||||
|
||||
await store.saveGatewayToken('token-secret');
|
||||
await store.saveGatewayPassword('password-secret');
|
||||
await store.saveAiGatewayApiKey('ai-gateway-secret');
|
||||
|
||||
expect(await store.loadGatewayToken(), 'token-secret');
|
||||
expect(await store.loadGatewayPassword(), 'password-secret');
|
||||
expect(await store.loadAiGatewayApiKey(), 'ai-gateway-secret');
|
||||
final secretDirectory = Directory('${tempDirectory.path}/secrets');
|
||||
final secretFiles = await secretDirectory
|
||||
.list()
|
||||
.where((entity) => entity is File)
|
||||
.toList();
|
||||
expect(secretFiles, hasLength(3));
|
||||
expect(
|
||||
secretFiles.every((entity) => entity.path.endsWith('.secret')),
|
||||
isTrue,
|
||||
);
|
||||
expect(store.persistentWriteFailures.secrets, isNull);
|
||||
if (!Platform.isWindows) {
|
||||
expect((await secretDirectory.stat()).modeString(), 'rwx------');
|
||||
for (final entity in secretFiles) {
|
||||
expect((await entity.stat()).modeString(), 'rw-------');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore exposes an explicit secrets write failure when durable secret storage is unavailable',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-secrets-memory-fallback-',
|
||||
);
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async => tempDirectory.path,
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
'/dev/null/xworkmate/secrets',
|
||||
);
|
||||
|
||||
await store.saveGatewayToken('token-secret');
|
||||
|
||||
expect(await store.loadGatewayToken(), 'token-secret');
|
||||
expect(store.persistentWriteFailures.secrets, isNotNull);
|
||||
expect(
|
||||
store.persistentWriteFailures.secrets?.scope,
|
||||
PersistentStoreScope.secrets,
|
||||
);
|
||||
expect(store.persistentWriteFailures.secrets?.operation, 'writeSecret');
|
||||
expect(
|
||||
store.persistentWriteFailures.secrets?.message,
|
||||
contains('Persistent secret'),
|
||||
);
|
||||
|
||||
final reloadedStore = SecureConfigStore(
|
||||
databasePathResolver: () async => tempDirectory.path,
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
'/dev/null/xworkmate/secrets',
|
||||
);
|
||||
expect(await reloadedStore.loadGatewayToken(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore clears gateway token without touching snapshot',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-clear-token-',
|
||||
);
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
|
||||
await store.saveGatewayToken('token-secret');
|
||||
expect(await store.loadGatewayToken(), 'token-secret');
|
||||
|
||||
await store.clearGatewayToken();
|
||||
|
||||
expect(await store.loadGatewayToken(), isNull);
|
||||
expect(
|
||||
(await store.loadSecureRefs()).containsKey('gateway_token'),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore falls back to file-backed device identity and token across instances',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-secure-store-',
|
||||
);
|
||||
|
||||
final identity = const LocalDeviceIdentity(
|
||||
deviceId: 'device-123',
|
||||
publicKeyBase64Url: 'public-key',
|
||||
privateKeyBase64Url: 'private-key',
|
||||
createdAtMs: 1700000000000,
|
||||
);
|
||||
final firstStore = SecureConfigStore(
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await firstStore.saveDeviceIdentity(identity);
|
||||
await firstStore.saveDeviceToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: 'operator',
|
||||
token: 'device-token',
|
||||
);
|
||||
|
||||
final secondStore = SecureConfigStore(
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final reloadedIdentity = await secondStore.loadDeviceIdentity();
|
||||
final reloadedToken = await secondStore.loadDeviceToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: 'operator',
|
||||
);
|
||||
|
||||
expect(reloadedIdentity?.deviceId, identity.deviceId);
|
||||
expect(
|
||||
reloadedIdentity?.publicKeyBase64Url,
|
||||
identity.publicKeyBase64Url,
|
||||
);
|
||||
expect(
|
||||
reloadedIdentity?.privateKeyBase64Url,
|
||||
identity.privateKeyBase64Url,
|
||||
);
|
||||
expect(reloadedToken, 'device-token');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
379
test/runtime/secure_config_store_suite_settings.part.dart
Normal file
379
test/runtime/secure_config_store_suite_settings.part.dart
Normal file
@ -0,0 +1,379 @@
|
||||
part of 'secure_config_store_suite.dart';
|
||||
|
||||
void _registerSecureConfigStoreSuiteSettingsTests() {
|
||||
group('Settings storage', () {
|
||||
test(
|
||||
'SecureConfigStore persists settings and secure refs in test runners',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-',
|
||||
);
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'tester',
|
||||
accountWorkspace: 'QA',
|
||||
codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli,
|
||||
codexCliPath: '/opt/homebrew/bin/codex',
|
||||
assistantNavigationDestinations: const <AssistantFocusEntry>[
|
||||
AssistantFocusEntry.aiGateway,
|
||||
AssistantFocusEntry.secrets,
|
||||
],
|
||||
gatewayProfiles: replaceGatewayProfileAt(
|
||||
SettingsSnapshot.defaults().gatewayProfiles,
|
||||
kGatewayRemoteProfileIndex,
|
||||
GatewayConnectionProfile.defaultsRemote().copyWith(
|
||||
host: 'gateway.example.com',
|
||||
port: 9443,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await store.saveSettingsSnapshot(snapshot);
|
||||
await store.saveGatewayToken('token-secret');
|
||||
await store.saveGatewayPassword('password-secret');
|
||||
await store.saveVaultToken('vault-secret');
|
||||
await store.saveAiGatewayApiKey('ai-gateway-secret');
|
||||
|
||||
final loadedSnapshot = await store.loadSettingsSnapshot();
|
||||
final secureRefs = await store.loadSecureRefs();
|
||||
|
||||
expect(loadedSnapshot.accountUsername, 'tester');
|
||||
expect(loadedSnapshot.accountWorkspace, 'QA');
|
||||
expect(
|
||||
loadedSnapshot.codeAgentRuntimeMode,
|
||||
CodeAgentRuntimeMode.externalCli,
|
||||
);
|
||||
expect(loadedSnapshot.codexCliPath, '/opt/homebrew/bin/codex');
|
||||
expect(
|
||||
loadedSnapshot.assistantNavigationDestinations,
|
||||
const <AssistantFocusEntry>[
|
||||
AssistantFocusEntry.aiGateway,
|
||||
AssistantFocusEntry.secrets,
|
||||
],
|
||||
);
|
||||
expect(
|
||||
loadedSnapshot.primaryRemoteGatewayProfile.host,
|
||||
'gateway.example.com',
|
||||
);
|
||||
expect(loadedSnapshot.primaryRemoteGatewayProfile.port, 9443);
|
||||
expect(secureRefs['gateway_token'], 'token-secret');
|
||||
expect(secureRefs['gateway_password'], 'password-secret');
|
||||
expect(secureRefs['vault_token'], 'vault-secret');
|
||||
expect(secureRefs['ai_gateway_api_key'], 'ai-gateway-secret');
|
||||
expect(SecureConfigStore.maskValue('token-secret'), 'tok••••ret');
|
||||
expect(SecureConfigStore.maskValue(''), 'Not set');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore persists sqlite-backed settings across instances',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-cross-instance-',
|
||||
);
|
||||
final databasePath = '${tempDirectory.path}/settings.sqlite3';
|
||||
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'sqlite-user',
|
||||
accountWorkspace: 'sqlite-workspace',
|
||||
gatewayProfiles: replaceGatewayProfileAt(
|
||||
SettingsSnapshot.defaults().gatewayProfiles,
|
||||
kGatewayRemoteProfileIndex,
|
||||
GatewayConnectionProfile.defaultsRemote().copyWith(
|
||||
host: 'sqlite.example.com',
|
||||
port: 443,
|
||||
),
|
||||
),
|
||||
);
|
||||
final entry = SecretAuditEntry(
|
||||
timeLabel: '10:00',
|
||||
action: 'Updated',
|
||||
provider: 'Vault',
|
||||
target: 'vault_token',
|
||||
module: 'Settings',
|
||||
status: 'Success',
|
||||
);
|
||||
|
||||
final firstStore = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await firstStore.saveSettingsSnapshot(snapshot);
|
||||
await firstStore.appendAudit(entry);
|
||||
|
||||
final secondStore = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final loadedSnapshot = await secondStore.loadSettingsSnapshot();
|
||||
final loadedAudit = await secondStore.loadAuditTrail();
|
||||
|
||||
expect(loadedSnapshot.accountUsername, 'sqlite-user');
|
||||
expect(loadedSnapshot.accountWorkspace, 'sqlite-workspace');
|
||||
expect(
|
||||
loadedSnapshot.primaryRemoteGatewayProfile.host,
|
||||
'sqlite.example.com',
|
||||
);
|
||||
expect(loadedAudit, hasLength(1));
|
||||
expect(loadedAudit.first.provider, 'Vault');
|
||||
expect(loadedAudit.first.target, 'vault_token');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore keeps settings in memory when no durable path is available',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
const unavailablePath = '/dev/null/xworkmate/settings.sqlite3';
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async => unavailablePath,
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
'/dev/null/xworkmate/secrets',
|
||||
);
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'memory-user',
|
||||
);
|
||||
|
||||
await store.saveSettingsSnapshot(snapshot);
|
||||
final loadedSnapshot = await store.loadSettingsSnapshot();
|
||||
final writeFailures = store.persistentWriteFailures;
|
||||
final reloadedSnapshot = await SecureConfigStore(
|
||||
databasePathResolver: () async => unavailablePath,
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
'/dev/null/xworkmate/secrets',
|
||||
).loadSettingsSnapshot();
|
||||
|
||||
expect(loadedSnapshot.accountUsername, 'memory-user');
|
||||
expect(writeFailures.settings, isNotNull);
|
||||
expect(writeFailures.settings?.scope, PersistentStoreScope.settings);
|
||||
expect(writeFailures.settings?.operation, 'saveSettingsSnapshot');
|
||||
expect(
|
||||
writeFailures.settings?.message,
|
||||
contains('Persistent settings'),
|
||||
);
|
||||
expect(
|
||||
reloadedSnapshot.accountUsername,
|
||||
SettingsSnapshot.defaults().accountUsername,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore exposes an explicit tasks write failure when durable task storage is unavailable',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
const unavailablePath = '/dev/null/xworkmate/settings.sqlite3';
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async => unavailablePath,
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
'/dev/null/xworkmate/secrets',
|
||||
);
|
||||
|
||||
await store.saveAssistantThreadRecords(const <AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:memory-only',
|
||||
title: 'Memory only',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.local,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[],
|
||||
),
|
||||
]);
|
||||
|
||||
final loadedRecords = await store.loadAssistantThreadRecords();
|
||||
final writeFailures = store.persistentWriteFailures;
|
||||
|
||||
expect(loadedRecords, hasLength(1));
|
||||
expect(loadedRecords.first.sessionKey, 'draft:memory-only');
|
||||
expect(writeFailures.tasks, isNotNull);
|
||||
expect(writeFailures.tasks?.scope, PersistentStoreScope.tasks);
|
||||
expect(writeFailures.tasks?.operation, 'saveAssistantThreadRecords');
|
||||
expect(writeFailures.tasks?.message, contains('Persistent task path'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore auto-creates an explicit settings directory on first install',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-missing-settings-path-',
|
||||
);
|
||||
final existingSecretsDirectory = Directory(
|
||||
'${tempDirectory.path}/secrets',
|
||||
);
|
||||
await existingSecretsDirectory.create(recursive: true);
|
||||
final explicitSettingsPath =
|
||||
'${tempDirectory.path}/settings/${SettingsStore.databaseFileName}';
|
||||
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async => explicitSettingsPath,
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
existingSecretsDirectory.path,
|
||||
);
|
||||
|
||||
final snapshot = await store.loadSettingsSnapshot();
|
||||
|
||||
expect(
|
||||
snapshot.accountUsername,
|
||||
SettingsSnapshot.defaults().accountUsername,
|
||||
);
|
||||
expect(
|
||||
await Directory('${tempDirectory.path}/settings/config').exists(),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
await File(
|
||||
'${tempDirectory.path}/settings/config/settings.yaml',
|
||||
).exists(),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore auto-creates an explicit secrets directory on first install',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-missing-secrets-path-',
|
||||
);
|
||||
final existingSettingsDirectory = Directory(
|
||||
'${tempDirectory.path}/settings',
|
||||
);
|
||||
await existingSettingsDirectory.create(recursive: true);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async =>
|
||||
'${existingSettingsDirectory.path}/${SettingsStore.databaseFileName}',
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
'${tempDirectory.path}/secrets',
|
||||
);
|
||||
|
||||
await store.saveGatewayToken('token-secret');
|
||||
|
||||
expect(
|
||||
await Directory('${tempDirectory.path}/secrets').exists(),
|
||||
isTrue,
|
||||
);
|
||||
expect(await store.loadGatewayToken(), 'token-secret');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore persists across instances using default support root when overrides fail',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-default-support-',
|
||||
);
|
||||
final defaultSupportRoot =
|
||||
'${tempDirectory.path}/plus.svc.xworkmate/xworkmate';
|
||||
|
||||
final firstStore = SecureConfigStore(
|
||||
databasePathResolver: () async =>
|
||||
throw StateError('primary unavailable'),
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
throw StateError('fallback unavailable'),
|
||||
defaultSupportDirectoryPathResolver: () async => defaultSupportRoot,
|
||||
);
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'fallback-user',
|
||||
);
|
||||
await firstStore.saveSettingsSnapshot(snapshot);
|
||||
await firstStore.saveGatewayToken('fallback-token');
|
||||
|
||||
final secondStore = SecureConfigStore(
|
||||
databasePathResolver: () async =>
|
||||
throw StateError('primary unavailable'),
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
throw StateError('fallback unavailable'),
|
||||
defaultSupportDirectoryPathResolver: () async => defaultSupportRoot,
|
||||
);
|
||||
|
||||
final loadedSnapshot = await secondStore.loadSettingsSnapshot();
|
||||
final loadedToken = await secondStore.loadGatewayToken();
|
||||
final settingsFile = File('$defaultSupportRoot/config/settings.yaml');
|
||||
final secretDirectory = Directory('$defaultSupportRoot/secrets');
|
||||
|
||||
expect(await settingsFile.exists(), isTrue);
|
||||
expect(await secretDirectory.exists(), isTrue);
|
||||
expect(loadedSnapshot.accountUsername, 'fallback-user');
|
||||
expect(loadedToken, 'fallback-token');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore persists multi-agent settings without secrets in snapshot json',
|
||||
() async {
|
||||
final tempDirectory = await _createTempDirectory(
|
||||
'xworkmate-config-store-multi-agent-',
|
||||
);
|
||||
final store = _createStoreFromTempDirectory(tempDirectory);
|
||||
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
multiAgent: MultiAgentConfig.defaults().copyWith(
|
||||
enabled: true,
|
||||
autoSync: false,
|
||||
framework: MultiAgentFramework.aris,
|
||||
arisEnabled: true,
|
||||
arisBundleVersion: '2026-03-19-dd663c1',
|
||||
arisCompatStatus: 'ready',
|
||||
aiGatewayInjectionPolicy: AiGatewayInjectionPolicy.launchScoped,
|
||||
architect: const AgentWorkerConfig(
|
||||
role: MultiAgentRole.architect,
|
||||
cliTool: 'gemini',
|
||||
model: 'gemini-2.5-pro',
|
||||
enabled: true,
|
||||
),
|
||||
managedSkills: const <ManagedSkillEntry>[
|
||||
ManagedSkillEntry(
|
||||
key: 'calm_compact_workspace_system',
|
||||
label: 'Calm Compact Workspace System',
|
||||
source:
|
||||
'/Users/test/.agents/skills/calm_compact_workspace_system',
|
||||
selected: true,
|
||||
),
|
||||
],
|
||||
managedMcpServers: const <ManagedMcpServerEntry>[
|
||||
ManagedMcpServerEntry(
|
||||
id: 'xworkmate/gateway',
|
||||
name: 'XWorkmate Gateway',
|
||||
transport: 'stdio',
|
||||
command: 'xworkmate-mcp',
|
||||
url: '',
|
||||
args: <String>['--stdio'],
|
||||
envKeys: <String>[],
|
||||
enabled: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await store.saveSettingsSnapshot(snapshot);
|
||||
final loadedSnapshot = await store.loadSettingsSnapshot();
|
||||
final encoded = loadedSnapshot.toJsonString();
|
||||
|
||||
expect(loadedSnapshot.multiAgent.enabled, isTrue);
|
||||
expect(loadedSnapshot.multiAgent.autoSync, isFalse);
|
||||
expect(loadedSnapshot.multiAgent.framework, MultiAgentFramework.aris);
|
||||
expect(loadedSnapshot.multiAgent.arisEnabled, isTrue);
|
||||
expect(
|
||||
loadedSnapshot.multiAgent.arisBundleVersion,
|
||||
'2026-03-19-dd663c1',
|
||||
);
|
||||
expect(loadedSnapshot.multiAgent.arisCompatStatus, 'ready');
|
||||
expect(
|
||||
loadedSnapshot.multiAgent.aiGatewayInjectionPolicy,
|
||||
AiGatewayInjectionPolicy.launchScoped,
|
||||
);
|
||||
expect(loadedSnapshot.multiAgent.architect.model, 'gemini-2.5-pro');
|
||||
expect(loadedSnapshot.multiAgent.managedSkills, hasLength(1));
|
||||
expect(loadedSnapshot.multiAgent.managedMcpServers, hasLength(1));
|
||||
expect(encoded, contains('"multiAgent"'));
|
||||
expect(encoded, isNot(contains('ai-gateway-secret')));
|
||||
expect(encoded, isNot(contains('gateway_token')));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user