refactor(batch6): split runtime suite core tests by behavior domains

This commit is contained in:
Haitao Pan 2026-03-28 12:06:32 +08:00
parent 74714cef8e
commit 323b77f0c6
27 changed files with 4737 additions and 4692 deletions

View File

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

View 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,
);
},
);
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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, '单机智能体');
},
);
});
}

View File

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

View File

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

View File

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

View File

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

View 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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)));
}
}
}

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

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

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