diff --git a/test/runtime/app_controller_ai_gateway_chat_suite.dart b/test/runtime/app_controller_ai_gateway_chat_suite.dart index a8d120c5..d22b5745 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite.dart @@ -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(); +} diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_chat.part.dart b/test/runtime/app_controller_ai_gateway_chat_suite_chat.part.dart new file mode 100644 index 00000000..01e086e5 --- /dev/null +++ b/test/runtime/app_controller_ai_gateway_chat_suite_chat.part.dart @@ -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 [], + 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 ['qwen2.5-coder:latest'], + selectedModels: const ['qwen2.5-coder:latest'], + ), + defaultModel: 'gpt-5.4', + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + mountTargets: _withAvailableMountTargets( + controller.settings.multiAgent.mountTargets, + const [], + ), + ), + ), + 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 [], + 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'], >[ + {'role': 'user', 'content': firstQuestion}, + ]); + expect(server.requests.last['messages'], >[ + {'role': 'user', 'content': firstQuestion}, + {'role': 'assistant', 'content': 'FIRST_REPLY'}, + {'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 [], + 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 ['moonshotai/kimi-k2.5'], + selectedModels: const ['moonshotai/kimi-k2.5'], + ), + defaultModel: 'moonshotai/kimi-k2.5', + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + mountTargets: _withAvailableMountTargets( + controller.settings.multiAgent.mountTargets, + const [], + ), + ), + ), + 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 [], + 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 ['z-ai/glm5'], + selectedModels: const ['z-ai/glm5'], + ), + defaultModel: 'z-ai/glm5', + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + mountTargets: _withAvailableMountTargets( + controller.settings.multiAgent.mountTargets, + const [], + ), + ), + ), + 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, + ); + }, + ); + }); +} diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_core.part.dart b/test/runtime/app_controller_ai_gateway_chat_suite_core.part.dart index 6b902091..4cdb490c 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite_core.part.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite_core.part.dart @@ -1,1176 +1 @@ part of 'app_controller_ai_gateway_chat_suite.dart'; - -void main() { - test( - 'AppController streams and restores persistent Single Agent conversation turns', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-ai-gateway-chat-', - ); - final server = await _FakeAiGatewayServer.start( - responseMode: _AiGatewayResponseMode.sse, - ); - addTearDown(() async { - await server.close(); - if (await tempDirectory.exists()) { - 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, - availableSingleAgentProvidersOverride: const [], - runtimeCoordinator: RuntimeCoordinator( - gateway: gateway, - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: _FallbackOnlySingleAgentRunner(), - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - await controller.settingsController.saveAiGatewayApiKey('live-key'); - await controller.saveSettings( - controller.settings.copyWith( - aiGateway: controller.settings.aiGateway.copyWith( - baseUrl: server.baseUrl, - availableModels: const ['qwen2.5-coder:latest'], - selectedModels: const ['qwen2.5-coder:latest'], - ), - defaultModel: 'gpt-5.4', - multiAgent: controller.settings.multiAgent.copyWith( - autoSync: false, - mountTargets: _withAvailableMountTargets( - controller.settings.multiAgent.mountTargets, - const [], - ), - ), - ), - 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 = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - final secondGateway = _FakeGatewayRuntime(store: secondStore); - final secondController = AppController( - store: secondStore, - availableSingleAgentProvidersOverride: const [], - runtimeCoordinator: RuntimeCoordinator( - gateway: secondGateway, - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: _FallbackOnlySingleAgentRunner(), - ); - addTearDown(secondController.dispose); - - await _waitFor(() => !secondController.initializing); - 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'], >[ - {'role': 'user', 'content': firstQuestion}, - ]); - expect(server.requests.last['messages'], >[ - {'role': 'user', 'content': firstQuestion}, - {'role': 'assistant', 'content': 'FIRST_REPLY'}, - {'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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-ai-gateway-json-fallback-', - ); - final server = await _FakeAiGatewayServer.start( - responseMode: _AiGatewayResponseMode.json, - ); - addTearDown(() async { - await server.close(); - if (await tempDirectory.exists()) { - await _deleteDirectoryWithRetry(tempDirectory); - } - }); - - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - final controller = AppController( - store: store, - availableSingleAgentProvidersOverride: const [], - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(store: store), - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: _FallbackOnlySingleAgentRunner(), - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - await controller.settingsController.saveAiGatewayApiKey('live-key'); - await controller.saveSettings( - controller.settings.copyWith( - aiGateway: controller.settings.aiGateway.copyWith( - baseUrl: server.baseUrl, - availableModels: const ['moonshotai/kimi-k2.5'], - selectedModels: const ['moonshotai/kimi-k2.5'], - ), - defaultModel: 'moonshotai/kimi-k2.5', - multiAgent: controller.settings.multiAgent.copyWith( - autoSync: false, - mountTargets: _withAvailableMountTargets( - controller.settings.multiAgent.mountTargets, - const [], - ), - ), - ), - 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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-ai-gateway-abort-', - ); - final server = await _FakeAiGatewayServer.start( - responseMode: _AiGatewayResponseMode.sse, - ); - addTearDown(() async { - await server.close(); - if (await tempDirectory.exists()) { - await _deleteDirectoryWithRetry(tempDirectory); - } - }); - - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - final controller = AppController( - store: store, - availableSingleAgentProvidersOverride: const [], - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(store: store), - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: _FallbackOnlySingleAgentRunner(), - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - await controller.settingsController.saveAiGatewayApiKey('live-key'); - await controller.saveSettings( - controller.settings.copyWith( - aiGateway: controller.settings.aiGateway.copyWith( - baseUrl: server.baseUrl, - availableModels: const ['z-ai/glm5'], - selectedModels: const ['z-ai/glm5'], - ), - defaultModel: 'z-ai/glm5', - multiAgent: controller.settings.multiAgent.copyWith( - autoSync: false, - mountTargets: _withAvailableMountTargets( - controller.settings.multiAgent.mountTargets, - const [], - ), - ), - ), - 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, - ); - }, - ); - - test( - 'AppController uses the selected Single Agent provider before AI Chat fallback', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-single-agent-provider-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await _deleteDirectoryWithRetry(tempDirectory); - } - }); - - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - 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 = AppController( - store: store, - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.opencode, - ], - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(store: store), - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: runner, - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - 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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-single-agent-provider-debug-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await _deleteDirectoryWithRetry(tempDirectory); - } - }); - - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - 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 = AppController( - store: store, - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.opencode, - ], - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(store: store), - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: runner, - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - 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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-single-agent-strict-provider-', - ); - final server = await _FakeAiGatewayServer.start( - responseMode: _AiGatewayResponseMode.json, - ); - addTearDown(() async { - await server.close(); - if (await tempDirectory.exists()) { - await _deleteDirectoryWithRetry(tempDirectory); - } - }); - - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - final runner = _FakeSingleAgentRunner( - resolvedProvider: null, - fallbackReason: 'Codex CLI is unavailable on this device.', - ); - final controller = AppController( - store: store, - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.claude, - ], - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(store: store), - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: runner, - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - await controller.settingsController.saveAiGatewayApiKey('live-key'); - await controller.saveSettings( - controller.settings.copyWith( - aiGateway: controller.settings.aiGateway.copyWith( - baseUrl: server.baseUrl, - availableModels: const ['moonshotai/kimi-k2.5'], - selectedModels: const ['moonshotai/kimi-k2.5'], - ), - defaultModel: 'moonshotai/kimi-k2.5', - multiAgent: controller.settings.multiAgent.copyWith( - autoSync: false, - mountTargets: _withAvailableMountTargets( - controller.settings.multiAgent.mountTargets, - const ['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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-single-agent-fallback-', - ); - final server = await _FakeAiGatewayServer.start( - responseMode: _AiGatewayResponseMode.json, - ); - addTearDown(() async { - await server.close(); - if (await tempDirectory.exists()) { - await _deleteDirectoryWithRetry(tempDirectory); - } - }); - - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - final runner = _FakeSingleAgentRunner( - resolvedProvider: null, - fallbackReason: 'Codex CLI is unavailable on this device.', - ); - final controller = AppController( - store: store, - availableSingleAgentProvidersOverride: const [], - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(store: store), - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: runner, - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - await controller.settingsController.saveAiGatewayApiKey('live-key'); - await controller.saveSettings( - controller.settings.copyWith( - aiGateway: controller.settings.aiGateway.copyWith( - baseUrl: server.baseUrl, - availableModels: const ['moonshotai/kimi-k2.5'], - selectedModels: const ['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, - ); - }, - ); - - test( - 'AppController uses the recorded thread workspace for Single Agent runs', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - '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); - addTearDown(() async { - if (await tempDirectory.exists()) { - await _deleteDirectoryWithRetry(tempDirectory); - } - }); - - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - await store.initialize(); - await store.saveSettingsSnapshot( - SettingsSnapshot.defaults().copyWith( - workspacePath: defaultWorkspace.path, - assistantExecutionTarget: AssistantExecutionTarget.singleAgent, - ), - ); - await store.saveAssistantThreadRecords([ - AssistantThreadRecord( - sessionKey: 'main', - messages: const [], - 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 = AppController( - store: store, - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.opencode, - ], - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(store: store), - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: runner, - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - 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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-single-agent-isolated-thread-cwd-', - ); - final defaultWorkspace = Directory( - '${tempDirectory.path}/default-workspace', - ); - await defaultWorkspace.create(recursive: true); - addTearDown(() async { - if (await tempDirectory.exists()) { - await _deleteDirectoryWithRetry(tempDirectory); - } - }); - - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - await store.initialize(); - await store.saveSettingsSnapshot( - SettingsSnapshot.defaults().copyWith( - workspacePath: defaultWorkspace.path, - assistantExecutionTarget: AssistantExecutionTarget.singleAgent, - ), - ); - - final runner = _FakeSingleAgentRunner( - resolvedProvider: SingleAgentProvider.opencode, - result: const SingleAgentRunResult( - provider: SingleAgentProvider.opencode, - output: 'THREAD_OK', - success: true, - errorMessage: '', - shouldFallbackToAiChat: false, - ), - ); - final controller = AppController( - store: store, - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.opencode, - ], - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(store: store), - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: runner, - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - controller.initializeAssistantThreadContext( - 'draft:artifact-thread', - title: 'Artifact Thread', - executionTarget: AssistantExecutionTarget.singleAgent, - ); - await controller.switchSession('draft:artifact-thread'); - await controller.sendChatMessage('检查当前线程目录', thinking: 'low'); - - const expectedWorkspaceSuffix = - '.xworkmate/threads/draft-artifact-thread'; - expect(runner.runCalls, 1); - expect( - runner.lastRequest?.workingDirectory, - '${defaultWorkspace.path}/$expectedWorkspaceSuffix', - ); - expect( - controller.assistantWorkspaceRefForSession('draft:artifact-thread'), - '${defaultWorkspace.path}/$expectedWorkspaceSuffix', - ); - expect( - Directory( - '${defaultWorkspace.path}/$expectedWorkspaceSuffix', - ).existsSync(), - isTrue, - ); - }, - ); - - test( - 'AppController adopts and reuses resolved remote single-agent thread workspaces', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-single-agent-remote-thread-cwd-', - ); - final defaultWorkspace = Directory( - '${tempDirectory.path}/default-workspace', - ); - await defaultWorkspace.create(recursive: true); - addTearDown(() async { - if (await tempDirectory.exists()) { - await _deleteDirectoryWithRetry(tempDirectory); - } - }); - - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - await store.initialize(); - await store.saveSettingsSnapshot( - SettingsSnapshot.defaults().copyWith( - workspacePath: defaultWorkspace.path, - assistantExecutionTarget: AssistantExecutionTarget.singleAgent, - ), - ); - - final runner = _FakeSingleAgentRunner( - resolvedProvider: SingleAgentProvider.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 = AppController( - store: store, - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.opencode, - ], - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(store: store), - codex: _FakeCodexRuntime(), - ), - singleAgentRunner: runner, - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - 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', - ); - }, - ); -} - -Future _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.delayed(Duration(milliseconds: 80 * (attempt + 1))); - } - } -} - -class _FakeGatewayRuntime extends GatewayRuntime { - _FakeGatewayRuntime({required super.store}) - : super(identityStore: DeviceIdentityStore(store)); - - final List connectedProfiles = - []; - GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(); - - @override - bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; - - @override - GatewayConnectionSnapshot get snapshot => _snapshot; - - @override - Stream get events => const Stream.empty(); - - @override - Future 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 disconnect({bool clearDesiredProfile = true}) async { - _snapshot = _snapshot.copyWith(status: RuntimeConnectionStatus.offline); - notifyListeners(); - } - - @override - Future request( - String method, { - Map? params, - Duration timeout = const Duration(seconds: 30), - }) async { - switch (method) { - case 'health': - case 'status': - return {'ok': true}; - case 'agents.list': - return {'agents': const [], 'mainKey': 'main'}; - case 'sessions.list': - return {'sessions': const []}; - case 'chat.history': - return {'messages': const []}; - case 'skills.status': - return {'skills': const []}; - case 'channels.status': - return { - 'channelMeta': const [], - 'channelLabels': const {}, - 'channelDetailLabels': const {}, - 'channelAccounts': const {}, - 'channelOrder': const [], - }; - case 'models.list': - return {'models': const []}; - case 'cron.list': - return {'jobs': const []}; - case 'device.pair.list': - return { - 'pending': const [], - 'paired': const [], - }; - case 'system-presence': - return const []; - default: - return {}; - } - } -} - -class _FakeCodexRuntime extends CodexRuntime { - @override - Future findCodexBinary() async => null; - - @override - Future 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 requests = []; - - @override - Future resolveProvider({ - required SingleAgentProvider selection, - required List availableProviders, - required String configuredCodexCliPath, - required String gatewayToken, - }) async { - resolveCalls += 1; - return SingleAgentProviderResolution( - selection: selection, - resolvedProvider: resolvedProvider, - fallbackReason: fallbackReason, - ); - } - - @override - Future 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 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> requests = >[]; - final Map> _completionGates = >{}; - - 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 close() async { - await _server.close(force: true); - } - - Future _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()); - - final reply = requestCount == 1 ? 'FIRST_REPLY' : 'SECOND_REPLY'; - if (_responseMode == _AiGatewayResponseMode.json) { - request.response.headers.contentType = ContentType.json; - request.response.write( - jsonEncode({ - 'id': 'chatcmpl-$requestCount', - 'choices': >[ - { - 'index': 0, - 'message': { - 'role': 'assistant', - 'content': reply, - }, - }, - ], - }), - ); - await request.response.close(); - continue; - } - - final gate = Completer(); - _completionGates[requestCount] = gate; - request.response.bufferOutput = false; - request.response.headers.set( - HttpHeaders.contentTypeHeader, - 'text/event-stream; charset=utf-8', - ); - request.response.write( - 'data: ${jsonEncode({ - 'choices': [ - { - 'delta': {'content': '${reply.split('_').first}_'}, - }, - ], - })}\n\n', - ); - await request.response.flush(); - await gate.future; - try { - request.response.write( - 'data: ${jsonEncode({ - 'choices': [ - { - 'delta': {'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 } - -List _withAvailableMountTargets( - List current, - List 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 _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.delayed(const Duration(milliseconds: 20)); - } -} diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_fakes.part.dart b/test/runtime/app_controller_ai_gateway_chat_suite_fakes.part.dart new file mode 100644 index 00000000..fe179079 --- /dev/null +++ b/test/runtime/app_controller_ai_gateway_chat_suite_fakes.part.dart @@ -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 connectedProfiles = + []; + GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(); + + @override + bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; + + @override + GatewayConnectionSnapshot get snapshot => _snapshot; + + @override + Stream get events => const Stream.empty(); + + @override + Future 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 disconnect({bool clearDesiredProfile = true}) async { + _snapshot = _snapshot.copyWith(status: RuntimeConnectionStatus.offline); + notifyListeners(); + } + + @override + Future request( + String method, { + Map? params, + Duration timeout = const Duration(seconds: 30), + }) async { + switch (method) { + case 'health': + case 'status': + return {'ok': true}; + case 'agents.list': + return {'agents': const [], 'mainKey': 'main'}; + case 'sessions.list': + return {'sessions': const []}; + case 'chat.history': + return {'messages': const []}; + case 'skills.status': + return {'skills': const []}; + case 'channels.status': + return { + 'channelMeta': const [], + 'channelLabels': const {}, + 'channelDetailLabels': const {}, + 'channelAccounts': const {}, + 'channelOrder': const [], + }; + case 'models.list': + return {'models': const []}; + case 'cron.list': + return {'jobs': const []}; + case 'device.pair.list': + return { + 'pending': const [], + 'paired': const [], + }; + case 'system-presence': + return const []; + default: + return {}; + } + } +} + +class _FakeCodexRuntime extends CodexRuntime { + @override + Future findCodexBinary() async => null; + + @override + Future 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 requests = []; + + @override + Future resolveProvider({ + required SingleAgentProvider selection, + required List availableProviders, + required String configuredCodexCliPath, + required String gatewayToken, + }) async { + resolveCalls += 1; + return SingleAgentProviderResolution( + selection: selection, + resolvedProvider: resolvedProvider, + fallbackReason: fallbackReason, + ); + } + + @override + Future 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 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> requests = >[]; + final Map> _completionGates = >{}; + + 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 close() async { + await _server.close(force: true); + } + + Future _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()); + + final reply = requestCount == 1 ? 'FIRST_REPLY' : 'SECOND_REPLY'; + if (_responseMode == _AiGatewayResponseMode.json) { + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'id': 'chatcmpl-$requestCount', + 'choices': >[ + { + 'index': 0, + 'message': { + 'role': 'assistant', + 'content': reply, + }, + }, + ], + }), + ); + await request.response.close(); + continue; + } + + final gate = Completer(); + _completionGates[requestCount] = gate; + request.response.bufferOutput = false; + request.response.headers.set( + HttpHeaders.contentTypeHeader, + 'text/event-stream; charset=utf-8', + ); + request.response.write( + 'data: ${jsonEncode({ + 'choices': [ + { + 'delta': {'content': '${reply.split('_').first}_'}, + }, + ], + })}\n\n', + ); + await request.response.flush(); + await gate.future; + try { + request.response.write( + 'data: ${jsonEncode({ + 'choices': [ + { + 'delta': {'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 } diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_fixtures.part.dart b/test/runtime/app_controller_ai_gateway_chat_suite_fixtures.part.dart new file mode 100644 index 00000000..fae74fdd --- /dev/null +++ b/test/runtime/app_controller_ai_gateway_chat_suite_fixtures.part.dart @@ -0,0 +1,91 @@ +part of 'app_controller_ai_gateway_chat_suite.dart'; + +Future _createAppController({ + required SecureConfigStore store, + List availableSingleAgentProvidersOverride = + const [], + 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 _createTempDirectory(String prefix) async { + SharedPreferences.setMockInitialValues({}); + 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 Function()? defaultSupportDirectoryPathResolver, +}) { + return SecureConfigStore( + enableSecureStorage: enableSecureStorage, + databasePathResolver: () async => '${tempDirectory.path}/$databaseFileName', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + defaultSupportDirectoryPathResolver: defaultSupportDirectoryPathResolver, + ); +} + +Future _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.delayed(Duration(milliseconds: 80 * (attempt + 1))); + } + } +} + +List _withAvailableMountTargets( + List current, + List 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 _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.delayed(const Duration(milliseconds: 20)); + } +} diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.part.dart b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.part.dart new file mode 100644 index 00000000..713bade0 --- /dev/null +++ b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.part.dart @@ -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.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.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.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 ['moonshotai/kimi-k2.5'], + selectedModels: const ['moonshotai/kimi-k2.5'], + ), + defaultModel: 'moonshotai/kimi-k2.5', + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + mountTargets: _withAvailableMountTargets( + controller.settings.multiAgent.mountTargets, + const ['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 [], + 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 ['moonshotai/kimi-k2.5'], + selectedModels: const ['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( + sessionKey: 'main', + messages: const [], + 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.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.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.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', + ); + }, + ); + }); +} diff --git a/test/runtime/app_controller_execution_target_switch_suite.dart b/test/runtime/app_controller_execution_target_switch_suite.dart index 87ea64ac..f046e76e 100644 --- a/test/runtime/app_controller_execution_target_switch_suite.dart +++ b/test/runtime/app_controller_execution_target_switch_suite.dart @@ -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(); +} diff --git a/test/runtime/app_controller_execution_target_switch_suite_connection.part.dart b/test/runtime/app_controller_execution_target_switch_suite_connection.part.dart new file mode 100644 index 00000000..ca2ca03f --- /dev/null +++ b/test/runtime/app_controller_execution_target_switch_suite_connection.part.dart @@ -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({}); + 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 ['qwen2.5-coder:latest'], + selectedModels: const ['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() + .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() + .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() + .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({}); + 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 ['qwen2.5-coder:latest'], + selectedModels: const ['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(); + gateway.holdNextConnect(connectGate); + + final switchFuture = controller.setAssistantExecutionTarget( + AssistantExecutionTarget.remote, + ); + var completed = false; + switchFuture.then((_) { + completed = true; + }); + + await Future.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({}); + 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 ['qwen2.5-coder:latest'], + selectedModels: const ['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({}); + 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(); + gateway.holdNextConnect(connectGate); + + final switchFuture = controller.setAssistantExecutionTarget( + AssistantExecutionTarget.remote, + ); + + await Future.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({}); + 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 ['qwen2.5-coder:latest'], + selectedModels: const ['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(); + 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, '单机智能体'); + }, + ); + }); +} diff --git a/test/runtime/app_controller_execution_target_switch_suite_core.part.dart b/test/runtime/app_controller_execution_target_switch_suite_core.part.dart index 34b2f1d5..f5425916 100644 --- a/test/runtime/app_controller_execution_target_switch_suite_core.part.dart +++ b/test/runtime/app_controller_execution_target_switch_suite_core.part.dart @@ -1,1026 +1,6 @@ part of 'app_controller_execution_target_switch_suite.dart'; -class _FakeGatewayRuntime extends GatewayRuntime { - _FakeGatewayRuntime({required super.store}) - : super(identityStore: DeviceIdentityStore(store)); - - final List connectedProfiles = - []; - final Set _failingModes = {}; - Completer? _connectGate; - Completer? _disconnectGate; - int disconnectCount = 0; - GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(); - - @override - bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; - - @override - GatewayConnectionSnapshot get snapshot => _snapshot; - - @override - Stream get events => const Stream.empty(); - - @override - Future 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 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 request( - String method, { - Map? params, - Duration timeout = const Duration(seconds: 30), - }) async { - switch (method) { - case 'health': - case 'status': - return {'ok': true}; - case 'agents.list': - return {'agents': const [], 'mainKey': 'main'}; - case 'sessions.list': - return {'sessions': const []}; - case 'chat.history': - return {'messages': const []}; - case 'skills.status': - return {'skills': const []}; - case 'channels.status': - return { - 'channelMeta': const [], - 'channelLabels': const {}, - 'channelDetailLabels': const {}, - 'channelAccounts': const {}, - 'channelOrder': const [], - }; - case 'models.list': - return {'models': const []}; - case 'cron.list': - return {'jobs': const []}; - case 'device.pair.list': - return { - 'pending': const [], - 'paired': const [], - }; - case 'system-presence': - return const []; - default: - return {}; - } - } - - void failNextConnect(RuntimeConnectionMode mode) { - _failingModes.add(mode); - } - - void holdNextConnect(Completer gate) { - _connectGate = gate; - } - - void holdNextDisconnect(Completer gate) { - _disconnectGate = gate; - } -} - -class _FakeCodexRuntime extends CodexRuntime { - @override - Future findCodexBinary() async => null; - - @override - Future stop() async {} -} - -Future _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.delayed(const Duration(milliseconds: 100)); - } - } -} - -void main() { - test( - 'AppController switches gateway connection when assistant execution target changes', - () async { - SharedPreferences.setMockInitialValues({}); - 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 ['qwen2.5-coder:latest'], - selectedModels: const ['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() - .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() - .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() - .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({}); - 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 ['qwen2.5-coder:latest'], - selectedModels: const ['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(); - gateway.holdNextConnect(connectGate); - - final switchFuture = controller.setAssistantExecutionTarget( - AssistantExecutionTarget.remote, - ); - var completed = false; - switchFuture.then((_) { - completed = true; - }); - - await Future.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({}); - 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 ['qwen2.5-coder:latest'], - selectedModels: const ['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({}); - 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(); - gateway.holdNextConnect(connectGate); - - final switchFuture = controller.setAssistantExecutionTarget( - AssistantExecutionTarget.remote, - ); - - await Future.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({}); - 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 ['qwen2.5-coder:latest'], - selectedModels: const ['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(); - 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, '单机智能体'); - }, - ); - - test( - 'AppController switches runtime state when the selected thread changes', - () async { - SharedPreferences.setMockInitialValues({}); - 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 ['qwen2.5-coder:latest'], - selectedModels: const ['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({}); - 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 ['qwen2.5-coder:latest'], - selectedModels: const ['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.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({}); - 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({}); - 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({}); - 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); - }, - ); -} - -Future _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.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); +void registerExecutionTargetSwitchSuiteTests() { + registerExecutionTargetSwitchConnectionTests(); + registerExecutionTargetSwitchThreadTests(); } diff --git a/test/runtime/app_controller_execution_target_switch_suite_fakes.part.dart b/test/runtime/app_controller_execution_target_switch_suite_fakes.part.dart new file mode 100644 index 00000000..a1dec90b --- /dev/null +++ b/test/runtime/app_controller_execution_target_switch_suite_fakes.part.dart @@ -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 connectedProfiles = + []; + final Set _failingModes = {}; + Completer? _connectGate; + Completer? _disconnectGate; + int disconnectCount = 0; + GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(); + + @override + bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; + + @override + GatewayConnectionSnapshot get snapshot => _snapshot; + + @override + Stream get events => const Stream.empty(); + + @override + Future 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 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 request( + String method, { + Map? params, + Duration timeout = const Duration(seconds: 30), + }) async { + switch (method) { + case 'health': + case 'status': + return {'ok': true}; + case 'agents.list': + return {'agents': const [], 'mainKey': 'main'}; + case 'sessions.list': + return {'sessions': const []}; + case 'chat.history': + return {'messages': const []}; + case 'skills.status': + return {'skills': const []}; + case 'channels.status': + return { + 'channelMeta': const [], + 'channelLabels': const {}, + 'channelDetailLabels': const {}, + 'channelAccounts': const {}, + 'channelOrder': const [], + }; + case 'models.list': + return {'models': const []}; + case 'cron.list': + return {'jobs': const []}; + case 'device.pair.list': + return { + 'pending': const [], + 'paired': const [], + }; + case 'system-presence': + return const []; + default: + return {}; + } + } + + void failNextConnect(RuntimeConnectionMode mode) { + _failingModes.add(mode); + } + + void holdNextConnect(Completer gate) { + _connectGate = gate; + } + + void holdNextDisconnect(Completer gate) { + _disconnectGate = gate; + } +} + +class _FakeCodexRuntime extends CodexRuntime { + @override + Future findCodexBinary() async => null; + + @override + Future stop() async {} +} + +Future _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.delayed(const Duration(milliseconds: 100)); + } + } +} diff --git a/test/runtime/app_controller_execution_target_switch_suite_fixtures.part.dart b/test/runtime/app_controller_execution_target_switch_suite_fixtures.part.dart new file mode 100644 index 00000000..bd1f1f29 --- /dev/null +++ b/test/runtime/app_controller_execution_target_switch_suite_fixtures.part.dart @@ -0,0 +1,25 @@ +part of 'app_controller_execution_target_switch_suite.dart'; + +Future _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.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); +} diff --git a/test/runtime/app_controller_execution_target_switch_suite_thread.part.dart b/test/runtime/app_controller_execution_target_switch_suite_thread.part.dart new file mode 100644 index 00000000..3934a017 --- /dev/null +++ b/test/runtime/app_controller_execution_target_switch_suite_thread.part.dart @@ -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({}); + 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 ['qwen2.5-coder:latest'], + selectedModels: const ['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({}); + 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 ['qwen2.5-coder:latest'], + selectedModels: const ['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.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({}); + 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({}); + 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({}); + 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); + }, + ); + }); +} diff --git a/test/runtime/app_controller_thread_skills_suite.dart b/test/runtime/app_controller_thread_skills_suite.dart index a22cfd8c..4a0dd443 100644 --- a/test/runtime/app_controller_thread_skills_suite.dart +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -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(); +} diff --git a/test/runtime/app_controller_thread_skills_suite_acp.part.dart b/test/runtime/app_controller_thread_skills_suite_acp.part.dart new file mode 100644 index 00000000..43336c3a --- /dev/null +++ b/test/runtime/app_controller_thread_skills_suite_acp.part.dart @@ -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({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-acp-skill-merge-', + ); + final acpServer = await _AcpSkillsStatusServer.start( + skills: const >[ + { + 'skillKey': 'acp-shared', + 'name': 'Shared Skill', + 'description': 'ACP should not override shared', + 'source': 'acp', + }, + { + 'skillKey': 'acp-workspace', + 'name': 'Workspace Skill', + 'description': 'ACP should not override workspace', + 'source': 'acp', + }, + { + '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( + sessionKey: 'main', + messages: const [], + 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.opencode, + ], + singleAgentSharedSkillScanRootOverrides: [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 [ + 'Shared Skill', + 'Workspace Skill', + 'ACP Only', + ]), + ); + expect( + importedSkills.firstWhere((item) => item.label == 'Shared Skill'), + isA() + .having( + (item) => item.description, + 'description', + 'Shared root wins', + ) + .having((item) => item.source, 'source', 'custom'), + ); + expect( + importedSkills.firstWhere((item) => item.label == 'Workspace Skill'), + isA() + .having( + (item) => item.description, + 'description', + 'Workspace wins', + ) + .having((item) => item.source, 'source', 'workspace'), + ); + expect( + importedSkills.firstWhere((item) => item.label == 'ACP Only'), + isA() + .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({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-acp-skill-error-', + ); + final acpServer = await _AcpSkillsStatusServer.start( + skills: const >[ + { + '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.opencode, + ], + singleAgentSharedSkillScanRootOverrides: [customRoot.path], + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + await _waitFor( + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .any((item) => item.label == 'ACP Only'), + ); + + acpServer.skillsError = { + '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 [ + 'Local Only', + ]); + }, + ); + + test( + 'AppController can return empty skills when neither public nor repo-local roots exist', + () async { + SharedPreferences.setMockInitialValues({}); + 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( + sessionKey: 'main', + messages: const [], + 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.opencode, + ], + singleAgentSharedSkillScanRootOverrides: const [], + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + await _waitFor( + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .isEmpty, + ); + + expect( + controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ), + isEmpty, + ); + }, + ); + }); +} diff --git a/test/runtime/app_controller_thread_skills_suite_core.part.dart b/test/runtime/app_controller_thread_skills_suite_core.part.dart index 2d94ed86..036aee34 100644 --- a/test/runtime/app_controller_thread_skills_suite_core.part.dart +++ b/test/runtime/app_controller_thread_skills_suite_core.part.dart @@ -1,1330 +1,8 @@ part of 'app_controller_thread_skills_suite.dart'; -void main() { - test( - 'AppController scans shared single-agent public roots on startup and shares them across providers', - () async { - SharedPreferences.setMockInitialValues({}); - 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.opencode, - SingleAgentProvider.claude, - ], - singleAgentSharedSkillScanRootOverrides: [ - 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 [ - '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 ['PPT'], - ); - - await controller.setSingleAgentProvider(SingleAgentProvider.claude); - await _waitFor( - () => - controller - .assistantImportedSkillsForSession(firstSessionKey) - .length == - 4, - ); - expect( - controller - .assistantSelectedSkillsForSession(firstSessionKey) - .map((skill) => skill.label), - const ['PPT'], - ); - }, - ); - - test( - 'AppController hot reloads authorized custom skill directories from settings.yaml', - () async { - SharedPreferences.setMockInitialValues({}); - 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.opencode, - ], - singleAgentSharedSkillScanRootOverrides: const [], - ); - 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(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), - [agentsRoot.path], - ); - }, - ); - - test( - 'AppController scans skills inside symlinked directories under shared roots', - () async { - if (Platform.isWindows) { - return; - } - SharedPreferences.setMockInitialValues({}); - 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.opencode, - ], - singleAgentSharedSkillScanRootOverrides: [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({}); - 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.opencode, - ], - singleAgentSharedSkillScanRootOverrides: const [ - '~/.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({}); - 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( - path: '${externalRepoSkill.path}/SKILL.md', - ), - ], - ), - ); - - final controller = AppController( - store: store, - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.opencode, - ], - singleAgentSharedSkillScanRootOverrides: [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), - ['${tempDirectory.path}/ai-workflow-craft/skills/docx'], - ); - }, - ); - - test( - 'AppController keeps thread-bound skills isolated and restores them after restart', - () async { - SharedPreferences.setMockInitialValues({}); - 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 createStore() { - return _createStore(tempDirectory.path); - } - - Future createController() async { - return AppController( - store: await createStore(), - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.opencode, - SingleAgentProvider.claude, - ], - singleAgentSharedSkillScanRootOverrides: [ - 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 ['PPT'], - ); - expect( - controller - .assistantSelectedSkillsForSession(taskB) - .map((skill) => skill.label), - const ['WordX'], - ); - expect( - controller - .assistantSelectedSkillsForSession(taskC) - .map((skill) => skill.label), - const ['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 ['PPT'], - ); - await restoredController.switchSession(taskB); - await _waitFor( - () => - restoredController - .assistantImportedSkillsForSession(taskB) - .length == - 4, - ); - expect( - restoredController - .assistantSelectedSkillsForSession(taskB) - .map((skill) => skill.label), - const ['WordX'], - ); - await restoredController.switchSession(taskC); - await _waitFor( - () => - restoredController - .assistantImportedSkillsForSession(taskC) - .length == - 4, - ); - expect( - restoredController - .assistantSelectedSkillsForSession(taskC) - .map((skill) => skill.label), - const ['Browser'], - ); - }, - ); - - test( - 'AppController uses thread workspaceRef for repo-local fallback', - () async { - SharedPreferences.setMockInitialValues({}); - 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( - sessionKey: 'main', - messages: const [], - 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.opencode, - ], - singleAgentSharedSkillScanRootOverrides: const [], - ); - 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({}); - 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( - sessionKey: 'main', - messages: const [], - 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.opencode, - ], - singleAgentSharedSkillScanRootOverrides: [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 [ - 'Shared Skill', - 'Global Only', - 'Workspace Only', - ]), - ); - }, - ); - - test( - 'AppController scans repo-local skills from workspace skills directory only', - () async { - SharedPreferences.setMockInitialValues({}); - 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( - sessionKey: 'main', - messages: const [], - 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.opencode, - ], - singleAgentSharedSkillScanRootOverrides: const [], - ); - 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, - ); - }, - ); - - test( - 'AppController merges ACP skills after shared roots and workspace skills', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-acp-skill-merge-', - ); - final acpServer = await _AcpSkillsStatusServer.start( - skills: const >[ - { - 'skillKey': 'acp-shared', - 'name': 'Shared Skill', - 'description': 'ACP should not override shared', - 'source': 'acp', - }, - { - 'skillKey': 'acp-workspace', - 'name': 'Workspace Skill', - 'description': 'ACP should not override workspace', - 'source': 'acp', - }, - { - '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( - sessionKey: 'main', - messages: const [], - 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.opencode, - ], - singleAgentSharedSkillScanRootOverrides: [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 [ - 'Shared Skill', - 'Workspace Skill', - 'ACP Only', - ]), - ); - expect( - importedSkills.firstWhere((item) => item.label == 'Shared Skill'), - isA() - .having( - (item) => item.description, - 'description', - 'Shared root wins', - ) - .having((item) => item.source, 'source', 'custom'), - ); - expect( - importedSkills.firstWhere((item) => item.label == 'Workspace Skill'), - isA() - .having((item) => item.description, 'description', 'Workspace wins') - .having((item) => item.source, 'source', 'workspace'), - ); - expect( - importedSkills.firstWhere((item) => item.label == 'ACP Only'), - isA() - .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({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-acp-skill-error-', - ); - final acpServer = await _AcpSkillsStatusServer.start( - skills: const >[ - { - '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.opencode, - ], - singleAgentSharedSkillScanRootOverrides: [customRoot.path], - ); - addTearDown(controller.dispose); - await _waitFor(() => !controller.initializing); - await _waitFor( - () => controller - .assistantImportedSkillsForSession(controller.currentSessionKey) - .any((item) => item.label == 'ACP Only'), - ); - - acpServer.skillsError = { - 'code': -32001, - 'message': 'skills refresh failed', - }; - await controller.refreshSingleAgentSkillsForSession( - controller.currentSessionKey, - ); - - final importedSkills = controller.assistantImportedSkillsForSession( - controller.currentSessionKey, - ); - expect(importedSkills.map((item) => item.label), const [ - 'Local Only', - ]); - }, - ); - - test( - 'AppController can return empty skills when neither public nor repo-local roots exist', - () async { - SharedPreferences.setMockInitialValues({}); - 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( - sessionKey: 'main', - messages: const [], - 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.opencode, - ], - singleAgentSharedSkillScanRootOverrides: const [], - ); - addTearDown(controller.dispose); - await _waitFor(() => !controller.initializing); - await _waitFor( - () => controller - .assistantImportedSkillsForSession(controller.currentSessionKey) - .isEmpty, - ); - - expect( - controller.assistantImportedSkillsForSession( - controller.currentSessionKey, - ), - isEmpty, - ); - }, - ); -} - -Future _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 _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.delayed(const Duration(milliseconds: 20)); - } -} - -Future _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, - ); -} - -class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService { - _FakeSkillDirectoryAccessService({required this.userHomeDirectory}); - - final String userHomeDirectory; - - @override - bool get isSupported => true; - - @override - Future resolveUserHomeDirectory() async { - return userHomeDirectory; - } - - @override - Future> authorizeDirectories({ - List suggestedPaths = const [], - }) async { - return const []; - } - - @override - Future authorizeDirectory({ - String suggestedPath = '', - }) async { - final normalized = normalizeAuthorizedSkillDirectoryPath(suggestedPath); - if (normalized.isEmpty) { - return null; - } - return AuthorizedSkillDirectory(path: normalized); - } - - @override - Future 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> skills; - Map? skillsError; - - int get port => _server.port; - - static Future<_AcpSkillsStatusServer> start({ - required List> skills, - }) async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final fake = _AcpSkillsStatusServer._( - server, - skills: skills.map((item) => Map.from(item)).toList(), - ); - unawaited(fake._listen()); - return fake; - } - - Future close() async { - await _server.close(force: true); - } - - Future _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 _handleRpc(HttpRequest request) async { - final body = await utf8.decodeStream(request); - final envelope = jsonDecode(body) as Map; - 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, { - 'jsonrpc': '2.0', - 'id': id, - 'result': { - 'singleAgent': true, - 'multiAgent': true, - 'providers': const ['opencode'], - 'capabilities': { - 'single_agent': true, - 'multi_agent': true, - 'providers': const ['opencode'], - }, - }, - }); - return; - case 'skills.status': - if (skillsError != null) { - await _writeSse(request, { - 'jsonrpc': '2.0', - 'id': id, - 'error': skillsError, - }); - return; - } - await _writeSse(request, { - 'jsonrpc': '2.0', - 'id': id, - 'result': {'skills': skills}, - }); - return; - default: - await _writeSse(request, { - 'jsonrpc': '2.0', - 'id': id, - 'error': { - 'code': -32601, - 'message': 'unknown method: $method', - }, - }); - } - } - - Future _writeSse( - HttpRequest request, - Map payload, - ) async { - request.response.write('data: ${jsonEncode(payload)}\n\n'); - await request.response.flush(); - await request.response.close(); - } +void registerThreadSkillsSuiteTests() { + registerThreadSkillsSharedRootTests(); + registerThreadSkillsThreadIsolationTests(); + registerThreadSkillsWorkspaceFallbackTests(); + registerThreadSkillsAcpTests(); } diff --git a/test/runtime/app_controller_thread_skills_suite_fakes.part.dart b/test/runtime/app_controller_thread_skills_suite_fakes.part.dart new file mode 100644 index 00000000..9c5b6c9f --- /dev/null +++ b/test/runtime/app_controller_thread_skills_suite_fakes.part.dart @@ -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 resolveUserHomeDirectory() async { + return userHomeDirectory; + } + + @override + Future> authorizeDirectories({ + List suggestedPaths = const [], + }) async { + return const []; + } + + @override + Future authorizeDirectory({ + String suggestedPath = '', + }) async { + final normalized = normalizeAuthorizedSkillDirectoryPath(suggestedPath); + if (normalized.isEmpty) { + return null; + } + return AuthorizedSkillDirectory(path: normalized); + } + + @override + Future 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> skills; + Map? skillsError; + + int get port => _server.port; + + static Future<_AcpSkillsStatusServer> start({ + required List> skills, + }) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = _AcpSkillsStatusServer._( + server, + skills: skills.map((item) => Map.from(item)).toList(), + ); + unawaited(fake._listen()); + return fake; + } + + Future close() async { + await _server.close(force: true); + } + + Future _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 _handleRpc(HttpRequest request) async { + final body = await utf8.decodeStream(request); + final envelope = jsonDecode(body) as Map; + 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, { + 'jsonrpc': '2.0', + 'id': id, + 'result': { + 'singleAgent': true, + 'multiAgent': true, + 'providers': const ['opencode'], + 'capabilities': { + 'single_agent': true, + 'multi_agent': true, + 'providers': const ['opencode'], + }, + }, + }); + return; + case 'skills.status': + if (skillsError != null) { + await _writeSse(request, { + 'jsonrpc': '2.0', + 'id': id, + 'error': skillsError, + }); + return; + } + await _writeSse(request, { + 'jsonrpc': '2.0', + 'id': id, + 'result': {'skills': skills}, + }); + return; + default: + await _writeSse(request, { + 'jsonrpc': '2.0', + 'id': id, + 'error': { + 'code': -32601, + 'message': 'unknown method: $method', + }, + }); + } + } + + Future _writeSse( + HttpRequest request, + Map payload, + ) async { + request.response.write('data: ${jsonEncode(payload)}\n\n'); + await request.response.flush(); + await request.response.close(); + } +} diff --git a/test/runtime/app_controller_thread_skills_suite_fixtures.part.dart b/test/runtime/app_controller_thread_skills_suite_fixtures.part.dart new file mode 100644 index 00000000..bd77236f --- /dev/null +++ b/test/runtime/app_controller_thread_skills_suite_fixtures.part.dart @@ -0,0 +1,66 @@ +part of 'app_controller_thread_skills_suite.dart'; + +Future _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 _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.delayed(const Duration(milliseconds: 20)); + } +} + +Future _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, + ); +} diff --git a/test/runtime/app_controller_thread_skills_suite_shared_roots.part.dart b/test/runtime/app_controller_thread_skills_suite_shared_roots.part.dart new file mode 100644 index 00000000..9c16d5df --- /dev/null +++ b/test/runtime/app_controller_thread_skills_suite_shared_roots.part.dart @@ -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({}); + 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.opencode, + SingleAgentProvider.claude, + ], + singleAgentSharedSkillScanRootOverrides: [ + 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 [ + '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 ['PPT'], + ); + + await controller.setSingleAgentProvider(SingleAgentProvider.claude); + await _waitFor( + () => + controller + .assistantImportedSkillsForSession(firstSessionKey) + .length == + 4, + ); + expect( + controller + .assistantSelectedSkillsForSession(firstSessionKey) + .map((skill) => skill.label), + const ['PPT'], + ); + }, + ); + + test( + 'AppController hot reloads authorized custom skill directories from settings.yaml', + () async { + SharedPreferences.setMockInitialValues({}); + 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.opencode, + ], + singleAgentSharedSkillScanRootOverrides: const [], + ); + 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(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), + [agentsRoot.path], + ); + }, + ); + + test( + 'AppController scans skills inside symlinked directories under shared roots', + () async { + if (Platform.isWindows) { + return; + } + SharedPreferences.setMockInitialValues({}); + 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.opencode, + ], + singleAgentSharedSkillScanRootOverrides: [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({}); + 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.opencode, + ], + singleAgentSharedSkillScanRootOverrides: const [ + '~/.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({}); + 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( + path: '${externalRepoSkill.path}/SKILL.md', + ), + ], + ), + ); + + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.opencode, + ], + singleAgentSharedSkillScanRootOverrides: [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), + ['${tempDirectory.path}/ai-workflow-craft/skills/docx'], + ); + }, + ); + }); +} diff --git a/test/runtime/app_controller_thread_skills_suite_thread_isolation.part.dart b/test/runtime/app_controller_thread_skills_suite_thread_isolation.part.dart new file mode 100644 index 00000000..4bc91755 --- /dev/null +++ b/test/runtime/app_controller_thread_skills_suite_thread_isolation.part.dart @@ -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({}); + 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 createStore() { + return _createStore(tempDirectory.path); + } + + Future createController() async { + return AppController( + store: await createStore(), + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.opencode, + SingleAgentProvider.claude, + ], + singleAgentSharedSkillScanRootOverrides: [ + 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 ['PPT'], + ); + expect( + controller + .assistantSelectedSkillsForSession(taskB) + .map((skill) => skill.label), + const ['WordX'], + ); + expect( + controller + .assistantSelectedSkillsForSession(taskC) + .map((skill) => skill.label), + const ['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 ['PPT'], + ); + await restoredController.switchSession(taskB); + await _waitFor( + () => + restoredController + .assistantImportedSkillsForSession(taskB) + .length == + 4, + ); + expect( + restoredController + .assistantSelectedSkillsForSession(taskB) + .map((skill) => skill.label), + const ['WordX'], + ); + await restoredController.switchSession(taskC); + await _waitFor( + () => + restoredController + .assistantImportedSkillsForSession(taskC) + .length == + 4, + ); + expect( + restoredController + .assistantSelectedSkillsForSession(taskC) + .map((skill) => skill.label), + const ['Browser'], + ); + }, + ); + }); +} diff --git a/test/runtime/app_controller_thread_skills_suite_workspace_fallback.part.dart b/test/runtime/app_controller_thread_skills_suite_workspace_fallback.part.dart new file mode 100644 index 00000000..0f0a83ed --- /dev/null +++ b/test/runtime/app_controller_thread_skills_suite_workspace_fallback.part.dart @@ -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({}); + 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( + sessionKey: 'main', + messages: const [], + 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.opencode, + ], + singleAgentSharedSkillScanRootOverrides: const [], + ); + 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({}); + 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( + sessionKey: 'main', + messages: const [], + 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.opencode, + ], + singleAgentSharedSkillScanRootOverrides: [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 [ + 'Shared Skill', + 'Global Only', + 'Workspace Only', + ]), + ); + }, + ); + + test( + 'AppController scans repo-local skills from workspace skills directory only', + () async { + SharedPreferences.setMockInitialValues({}); + 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( + sessionKey: 'main', + messages: const [], + 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.opencode, + ], + singleAgentSharedSkillScanRootOverrides: const [], + ); + 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, + ); + }, + ); + }); +} diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index e6ee7b4a..7f766e66 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -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(); +} diff --git a/test/runtime/secure_config_store_suite_compatibility.part.dart b/test/runtime/secure_config_store_suite_compatibility.part.dart new file mode 100644 index 00000000..2b8696c8 --- /dev/null +++ b/test/runtime/secure_config_store_suite_compatibility.part.dart @@ -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( + sessionKey: 'draft:legacy-1', + title: 'Legacy thread', + archived: false, + executionTarget: AssistantExecutionTarget.local, + messageViewMode: AssistantMessageViewMode.rendered, + messages: [ + 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({ + '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(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 ['/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({ + ...SettingsSnapshot.defaults().toJson(), + 'assistantExecutionTarget': 'aiGatewayOnly', + }); + + expect( + decoded.assistantExecutionTarget, + AssistantExecutionTarget.singleAgent, + ); + }, + ); + + test( + 'AssistantThreadRecord keeps compatibility with legacy json payloads', + () { + final decoded = AssistantThreadRecord.fromJson({ + 'sessionKey': 'legacy-thread', + 'messages': const [], + 'updatedAtMs': 1700000000000, + 'title': 'Legacy', + 'archived': false, + 'executionTarget': 'aiGatewayOnly', + 'messageViewMode': 'rendered', + 'discoveredSkills': const [ + { + '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: [], + 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({ + 'sessionKey': 'thread-legacy', + 'messages': const [], + 'updatedAtMs': 1700000000000, + 'title': 'Legacy Object Thread', + 'archived': false, + 'executionTarget': 'remote', + 'messageViewMode': 'rendered', + 'workspaceRef': 'object://thread/thread-legacy', + }); + + expect(decoded.workspaceRefKind, WorkspaceRefKind.objectStore); + }, + ); + }); +} diff --git a/test/runtime/secure_config_store_suite_core.part.dart b/test/runtime/secure_config_store_suite_core.part.dart index 5247f838..f3aedc4f 100644 --- a/test/runtime/secure_config_store_suite_core.part.dart +++ b/test/runtime/secure_config_store_suite_core.part.dart @@ -1,1168 +1 @@ part of 'secure_config_store_suite.dart'; - -void main() { - test( - 'SecureConfigStore persists settings and secure refs in test runners', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - final databasePath = '${tempDirectory.path}/settings.sqlite3'; - final store = SecureConfigStore( - databasePathResolver: () async => databasePath, - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - - final snapshot = SettingsSnapshot.defaults().copyWith( - accountUsername: 'tester', - accountWorkspace: 'QA', - codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli, - codexCliPath: '/opt/homebrew/bin/codex', - assistantNavigationDestinations: const [ - 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.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 keeps gateway secrets isolated per profile slot', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-profiles-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - final databasePath = '${tempDirectory.path}/settings.sqlite3'; - final store = SecureConfigStore( - databasePathResolver: () async => databasePath, - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - - 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 persists sqlite-backed settings across instances', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-cross-instance-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - 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({}); - 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({}); - const unavailablePath = '/dev/null/xworkmate/settings.sqlite3'; - final store = SecureConfigStore( - databasePathResolver: () async => unavailablePath, - fallbackDirectoryPathResolver: () async => - '/dev/null/xworkmate/secrets', - ); - - await store.saveAssistantThreadRecords(const [ - AssistantThreadRecord( - sessionKey: 'draft:memory-only', - title: 'Memory only', - archived: false, - executionTarget: AssistantExecutionTarget.local, - messageViewMode: AssistantMessageViewMode.rendered, - updatedAtMs: 1700000000000, - messages: [], - ), - ]); - - 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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-missing-settings-path-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - 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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-missing-secrets-path-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - 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({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-default-support-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - 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 writes secrets into the fixed secret path', () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-secret-path-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - 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({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-secrets-memory-fallback-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - 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 ignores legacy local-state files and keeps them untouched', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-local-state-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - final databasePath = '${tempDirectory.path}/settings.sqlite3'; - 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 => databasePath, - 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( - sessionKey: 'draft:legacy-1', - title: 'Legacy thread', - archived: false, - executionTarget: AssistantExecutionTarget.local, - messageViewMode: AssistantMessageViewMode.rendered, - updatedAtMs: 1700000000000, - messages: [ - GatewayChatMessage( - id: 'assistant-1', - role: 'assistant', - text: 'legacy message', - timestampMs: 1700000001000, - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ], - ), - ]; - SharedPreferences.setMockInitialValues({ - 'xworkmate.settings.snapshot': legacySnapshot.toJsonString(), - 'xworkmate.assistant.threads': jsonEncode( - legacyRecords.map((item) => item.toJson()).toList(growable: false), - ), - }); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-legacy-migrate-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - 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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-ignore-stray-files-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - 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( - 'SecureConfigStore persists multi-agent settings without secrets in snapshot json', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-multi-agent-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - final databasePath = '${tempDirectory.path}/settings.sqlite3'; - final store = SecureConfigStore( - databasePathResolver: () async => databasePath, - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - - 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( - key: 'calm_compact_workspace_system', - label: 'Calm Compact Workspace System', - source: - '/Users/test/.agents/skills/calm_compact_workspace_system', - selected: true, - ), - ], - managedMcpServers: const [ - ManagedMcpServerEntry( - id: 'xworkmate/gateway', - name: 'XWorkmate Gateway', - transport: 'stdio', - command: 'xworkmate-mcp', - url: '', - args: ['--stdio'], - envKeys: [], - 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'))); - }, - ); - - test( - 'SecureConfigStore persists assistant thread records and archived task keys', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-assistant-threads-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - final databasePath = '${tempDirectory.path}/settings.sqlite3'; - final store = SecureConfigStore( - databasePathResolver: () async => databasePath, - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - - final snapshot = SettingsSnapshot.defaults().copyWith( - assistantArchivedTaskKeys: const ['main'], - assistantCustomTaskTitles: const {'main': '研发任务'}, - assistantLastSessionKey: 'main', - ); - const records = [ - AssistantThreadRecord( - sessionKey: 'main', - title: '研发任务', - archived: true, - executionTarget: AssistantExecutionTarget.remote, - messageViewMode: AssistantMessageViewMode.raw, - importedSkills: [ - AssistantThreadSkillEntry( - key: '/tmp/imported-skill', - label: 'Imported Skill', - description: 'confirmed import', - sourcePath: '/tmp/imported-skill', - sourceLabel: 'custom/imported', - ), - ], - selectedSkillKeys: ['/tmp/imported-skill'], - assistantModelId: 'gpt-5.4-mini', - singleAgentProvider: SingleAgentProvider.claude, - gatewayEntryState: 'single-agent', - updatedAtMs: 1700000000000, - messages: [ - 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 [ - '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 [ - '/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('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(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 ['/Users/test/.agents/skills', '/etc/skills'], - ); - expect(decoded.authorizedSkillDirectories.first.bookmark, 'bookmark-data'); - }); - - test( - 'AssistantThreadRecord keeps compatibility with legacy json payloads', - () { - final decoded = AssistantThreadRecord.fromJson({ - 'sessionKey': 'legacy-thread', - 'messages': const [], - 'updatedAtMs': 1700000000000, - 'title': 'Legacy', - 'archived': false, - 'executionTarget': 'aiGatewayOnly', - 'messageViewMode': 'rendered', - 'discoveredSkills': const [ - { - '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: [], - 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({ - 'sessionKey': 'thread-legacy', - 'messages': const [], - 'updatedAtMs': 1700000000000, - 'title': 'Legacy Object Thread', - 'archived': false, - 'executionTarget': 'remote', - 'messageViewMode': 'rendered', - 'workspaceRef': 'object://thread/thread-legacy', - }); - - expect(decoded.workspaceRefKind, WorkspaceRefKind.objectStore); - }, - ); - - test( - 'SettingsSnapshot keeps compatibility with legacy target json values', - () { - final decoded = SettingsSnapshot.fromJson({ - ...SettingsSnapshot.defaults().toJson(), - 'assistantExecutionTarget': 'aiGatewayOnly', - }); - - expect( - decoded.assistantExecutionTarget, - AssistantExecutionTarget.singleAgent, - ); - }, - ); - - test( - 'SecureConfigStore restart keeps database state and legacy session files untouched', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-durable-restore-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - 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( - sessionKey: 'draft:backup-1', - title: '备份线程', - archived: false, - executionTarget: AssistantExecutionTarget.singleAgent, - messageViewMode: AssistantMessageViewMode.rendered, - updatedAtMs: 1700000000000, - messages: [ - 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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-clear-local-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - final databasePath = '${tempDirectory.path}/settings.sqlite3'; - final store = SecureConfigStore( - databasePathResolver: () async => databasePath, - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - final snapshot = SettingsSnapshot.defaults().copyWith( - accountUsername: 'clear-me', - assistantLastSessionKey: 'draft:clear-1', - ); - const records = [ - AssistantThreadRecord( - sessionKey: 'draft:clear-1', - title: '清理线程', - archived: false, - executionTarget: AssistantExecutionTarget.local, - messageViewMode: AssistantMessageViewMode.rendered, - updatedAtMs: 1700000000000, - messages: [], - ), - ]; - - 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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-dispose-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - 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'); - }, - ); - - test( - 'SecureConfigStore clears gateway token without touching snapshot', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-clear-token-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - final store = SecureConfigStore( - databasePathResolver: () async => - '${tempDirectory.path}/${SettingsStore.databaseFileName}', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - - 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 { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-secure-store-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - - 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'); - }, - ); -} diff --git a/test/runtime/secure_config_store_suite_fixtures.part.dart b/test/runtime/secure_config_store_suite_fixtures.part.dart new file mode 100644 index 00000000..454921b7 --- /dev/null +++ b/test/runtime/secure_config_store_suite_fixtures.part.dart @@ -0,0 +1,48 @@ +part of 'secure_config_store_suite.dart'; + +Future _createTempDirectory( + String prefix, { + bool resetSharedPreferences = true, +}) async { + if (resetSharedPreferences) { + SharedPreferences.setMockInitialValues({}); + } + 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 Function()? defaultSupportDirectoryPathResolver, +}) { + return SecureConfigStore( + enableSecureStorage: enableSecureStorage, + databasePathResolver: () async => + '${tempDirectory.path}/${SettingsStore.databaseFileName}', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + defaultSupportDirectoryPathResolver: defaultSupportDirectoryPathResolver, + ); +} + +Future _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.delayed(Duration(milliseconds: 80 * (attempt + 1))); + } + } +} diff --git a/test/runtime/secure_config_store_suite_lifecycle.part.dart b/test/runtime/secure_config_store_suite_lifecycle.part.dart new file mode 100644 index 00000000..103d26fe --- /dev/null +++ b/test/runtime/secure_config_store_suite_lifecycle.part.dart @@ -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 ['main'], + assistantCustomTaskTitles: const {'main': '研发任务'}, + assistantLastSessionKey: 'main', + ); + const records = [ + AssistantThreadRecord( + sessionKey: 'main', + title: '研发任务', + archived: true, + executionTarget: AssistantExecutionTarget.remote, + messageViewMode: AssistantMessageViewMode.raw, + importedSkills: [ + AssistantThreadSkillEntry( + key: '/tmp/imported-skill', + label: 'Imported Skill', + description: 'confirmed import', + sourcePath: '/tmp/imported-skill', + sourceLabel: 'custom/imported', + ), + ], + selectedSkillKeys: ['/tmp/imported-skill'], + assistantModelId: 'gpt-5.4-mini', + singleAgentProvider: SingleAgentProvider.claude, + gatewayEntryState: 'single-agent', + updatedAtMs: 1700000000000, + messages: [ + 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 [ + '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 [ + '/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({}); + 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( + sessionKey: 'draft:backup-1', + title: '备份线程', + archived: false, + executionTarget: AssistantExecutionTarget.singleAgent, + messageViewMode: AssistantMessageViewMode.rendered, + updatedAtMs: 1700000000000, + messages: [ + 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( + sessionKey: 'draft:clear-1', + title: '清理线程', + archived: false, + executionTarget: AssistantExecutionTarget.local, + messageViewMode: AssistantMessageViewMode.rendered, + updatedAtMs: 1700000000000, + messages: [], + ), + ]; + + 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'); + }, + ); + }); +} diff --git a/test/runtime/secure_config_store_suite_secrets.part.dart b/test/runtime/secure_config_store_suite_secrets.part.dart new file mode 100644 index 00000000..0437eae3 --- /dev/null +++ b/test/runtime/secure_config_store_suite_secrets.part.dart @@ -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({}); + 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'); + }, + ); + }); +} diff --git a/test/runtime/secure_config_store_suite_settings.part.dart b/test/runtime/secure_config_store_suite_settings.part.dart new file mode 100644 index 00000000..80309fdc --- /dev/null +++ b/test/runtime/secure_config_store_suite_settings.part.dart @@ -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.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.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({}); + 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({}); + const unavailablePath = '/dev/null/xworkmate/settings.sqlite3'; + final store = SecureConfigStore( + databasePathResolver: () async => unavailablePath, + fallbackDirectoryPathResolver: () async => + '/dev/null/xworkmate/secrets', + ); + + await store.saveAssistantThreadRecords(const [ + AssistantThreadRecord( + sessionKey: 'draft:memory-only', + title: 'Memory only', + archived: false, + executionTarget: AssistantExecutionTarget.local, + messageViewMode: AssistantMessageViewMode.rendered, + updatedAtMs: 1700000000000, + messages: [], + ), + ]); + + 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({}); + 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( + key: 'calm_compact_workspace_system', + label: 'Calm Compact Workspace System', + source: + '/Users/test/.agents/skills/calm_compact_workspace_system', + selected: true, + ), + ], + managedMcpServers: const [ + ManagedMcpServerEntry( + id: 'xworkmate/gateway', + name: 'XWorkmate Gateway', + transport: 'stdio', + command: 'xworkmate-mcp', + url: '', + args: ['--stdio'], + envKeys: [], + 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'))); + }, + ); + }); +}