diff --git a/docs/README_TESTING.md b/docs/README_TESTING.md index a8202916..3593b254 100644 --- a/docs/README_TESTING.md +++ b/docs/README_TESTING.md @@ -8,12 +8,6 @@ Run unit and widget tests: flutter test ``` -Run golden tests when the `test/golden` directory exists and contains golden test files: - -```bash -flutter test test/golden -``` - Run integration tests when the `integration_test` directory exists and contains integration test files: ```bash @@ -40,7 +34,7 @@ go test ./... ## CI Coverage - Pull requests in `xworkmate-app` use the `verify` stage as a static-analysis gate and always run `flutter analyze`. -- Widget, golden, integration, and Patrol suites are owned by their dedicated commands and release validation flows, not by the lightweight `verify` gate. +- Widget, integration, and Patrol suites are owned by their dedicated commands and release validation flows, not by the lightweight `verify` gate. - Pushes to `main`, version tags, and manual workflow runs publish build artifacts and update the GitHub Release entry for that release mode. - `xworkmate-bridge` Go tests run in the companion repository. - `release/*` branches run Patrol tests in addition to the PR chain. diff --git a/docs/architecture/settings-integration-configuration-model.md b/docs/architecture/settings-integration-configuration-model.md index 338beae1..b7aa4659 100644 --- a/docs/architecture/settings-integration-configuration-model.md +++ b/docs/architecture/settings-integration-configuration-model.md @@ -20,7 +20,7 @@ flowchart TD singleAgent / multiAgent"] H --> I["refreshSingleAgentCapabilitiesRuntimeInternal()"] - I --> J["bridgeAdvertisedProvidersInternal + I --> J["bridgeProviderCatalogInternal App 内唯一 provider 名单源"] I --> K["singleAgentCapabilitiesByProviderInternal App 内唯一 provider 可用性源"] @@ -33,8 +33,8 @@ flowchart TD codex / opencode / claude / gemini / aris / openclaw available / discoveryState"] - J --> P["configuredSingleAgentProviders - = bridgeAdvertisedProvidersInternal"] + J --> P["bridgeProviderCatalog + = bridgeProviderCatalogInternal"] P --> Q["singleAgentProviderOptions Composer / Thread Picker 唯一数据源"] diff --git a/docs/architecture/task-control-plane-unification.md b/docs/architecture/task-control-plane-unification.md index 846f43e3..2c0aaa56 100644 --- a/docs/architecture/task-control-plane-unification.md +++ b/docs/architecture/task-control-plane-unification.md @@ -93,7 +93,7 @@ flowchart TD subgraph APPSTATE["App-side truth sources"] D["refreshSingleAgentCapabilitiesRuntimeInternal()"] - E["bridgeAdvertisedProvidersInternal
App 内唯一 provider 名单源"] + E["bridgeProviderCatalogInternal
App 内唯一 provider 名单源"] F["singleAgentCapabilitiesByProviderInternal
App 内唯一 provider 可用性源"] G["refreshAcpCapabilitiesRuntimeInternal()"] H["GatewayAcpCapabilities"] @@ -105,7 +105,7 @@ flowchart TD end subgraph UISTATE["UI affordances"] - K["configuredSingleAgentProviders
Composer / Thread Picker provider source"] + K["bridgeProviderCatalog
Composer / Thread Picker provider source"] L["availableSingleAgentProviders
agent path visibility"] M["visible gateway affordances
只看 bridge capabilities / discovery"] E --> K diff --git a/docs/testing/test-case-coverage-matrix.md b/docs/testing/test-case-coverage-matrix.md index 55664790..65142776 100644 --- a/docs/testing/test-case-coverage-matrix.md +++ b/docs/testing/test-case-coverage-matrix.md @@ -52,7 +52,6 @@ | 失败恢复 | 错误 endpoint / 失败任务在原线程展示清晰错误,允许原线程重试 | 已设计 + 部分自动化 | runtime / feature | `test/runtime/gateway_acp_client_suite.dart` `test/features/settings_page_gateway_acp_messages_suite.dart` | 线程级失败回退还可继续加强 | | 结果表面一致性 | 本地执行型与在线执行型都通过统一 result surface 暴露结果 | 已设计 + 部分自动化 | runtime / feature | `test/runtime/desktop_thread_artifact_service_test.dart` | 统一 artifact surface 已有基础,但在线媒体任务仍是缺口 | | UI 冒烟 | 登录流程、首页流程、桌面导航流程、桌面设置流程 | 已自动化 | integration | `integration_test/login_flow_test.dart` `integration_test/home_flow_test.dart` `integration_test/desktop_navigation_flow_test.dart` `integration_test/desktop_settings_flow_test.dart` | 更偏入口联通验证,不替代业务细场景 | -| UI 表现稳定性 | Home / Login golden 基线 | 已自动化 | golden | `test/golden/home_golden_test.dart` `test/golden/login_golden_test.dart` | 当前 golden 覆盖面较窄,更多页面仍未纳入 | ## 4. 按层看当前测试重点 @@ -61,7 +60,6 @@ | runtime | endpoint 规范化、账户同步、secret 边界、线程归属、provider 切换、artifact 回写、线程隔离 | | feature | 设置页提示语与输入行为、assistant 页技能选择与提交、installed-skill E2E 壳层闭环 | | integration | 桌面端导航、设置入口联通、登录与首页 happy path 冒烟 | -| golden | 首页 / 登录页视觉基线 | | manual | 在线媒体任务、外部服务依赖场景、需要真实服务/真实账号/真实产物确认的 case | ## 5. 当前最值得关注的缺口 @@ -93,19 +91,6 @@ - 失败是否稳定回写线程消息 - 在线结果是否与本地结果保持统一展示模型 -### 5.3 Golden 覆盖面较窄 - -当前 golden 只有: - -- `home` -- `login` - -若后续设置页、assistant 页、skills 页发生明显 UI 变化,建议补充: - -- settings shell -- assistant home shell -- 关键线程结果面 - ## 6. 建议维护方式 后续新增 case 时,建议同时更新三处: @@ -137,4 +122,4 @@ - 媒体类技能自动化 - 在线长任务闭环 -- 更广的 UI golden 基线 +- 更贴近真实交互的桌面集成回归 diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index dd5b2022..17344420 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -122,6 +122,7 @@ class AppController extends ChangeNotifier { UiFeatureManifest? uiFeatureManifest, SkillDirectoryAccessService? skillDirectoryAccessService, AccountRuntimeClient Function(String baseUrl)? accountClientFactory, + Map? environmentOverride, List? singleAgentSharedSkillScanRootOverrides, ArisBundleRepository? arisBundleRepository, GoTaskServiceClient? goTaskServiceClient, @@ -191,6 +192,9 @@ class AppController extends ChangeNotifier { skillDirectoryAccessService ?? createSkillDirectoryAccessService(); singleAgentSharedSkillScanRootOverridesInternal = singleAgentSharedSkillScanRootOverrides?.toList(growable: false); + environmentOverrideInternal = environmentOverride == null + ? null + : Map.unmodifiable(environmentOverride); gatewayAcpClientInternal = GatewayAcpClient( endpointResolver: resolveGatewayAcpEndpointInternal, authorizationResolver: resolveGatewayAcpAuthorizationHeaderInternal, @@ -293,7 +297,7 @@ class AppController extends ChangeNotifier { GatewayAcpClient get gatewayAcpClientForTest => gatewayAcpClientInternal; - List bridgeAdvertisedProvidersInternal = + List bridgeProviderCatalogInternal = const []; final Map> assistantThreadMessagesInternal = >{}; @@ -350,6 +354,7 @@ class AppController extends ChangeNotifier { StreamSubscription? runtimeEventsSubscriptionInternal; bool disposedInternal = false; String resolvedUserHomeDirectoryInternal = resolveUserHomeDirectory(); + Map? environmentOverrideInternal; SettingsSnapshot lastObservedSettingsSnapshotInternal = SettingsSnapshot.defaults(); Future assistantThreadPersistQueueInternal = Future.value(); @@ -570,10 +575,21 @@ class AppController extends ChangeNotifier { profileIndex, ); - List get configuredSingleAgentProviders => - normalizeBridgeOwnedSingleAgentProviderList( - bridgeAdvertisedProvidersInternal, - ); + List get bridgeProviderCatalog => + normalizeSingleAgentProviderList(bridgeProviderCatalogInternal); + + SingleAgentProvider? bridgeProviderForId(String providerId) { + final normalizedProviderId = normalizeSingleAgentProviderId(providerId); + if (normalizedProviderId.isEmpty) { + return null; + } + for (final provider in bridgeProviderCatalog) { + if (provider.providerId == normalizedProviderId) { + return provider; + } + } + return null; + } List visibleAssistantExecutionTargets( Iterable supportedTargets, @@ -581,7 +597,7 @@ class AppController extends ChangeNotifier { final supported = supportedTargets.toSet(); final visible = []; if (supported.contains(AssistantExecutionTarget.singleAgent) && - configuredSingleAgentProviders.isNotEmpty) { + bridgeProviderCatalog.isNotEmpty) { visible.add(AssistantExecutionTarget.singleAgent); } if (supported.contains(AssistantExecutionTarget.gateway)) { @@ -599,31 +615,6 @@ class AppController extends ChangeNotifier { ]; } - bool isBridgeAdvertisedSingleAgentProviderInternal( - SingleAgentProvider provider, - ) { - if (provider.isUnspecified) { - return false; - } - return configuredSingleAgentProviders.any( - (item) => item.providerId == provider.providerId, - ); - } - - SingleAgentProvider? advertisedSingleAgentProviderInternal( - SingleAgentProvider selection, - ) { - if (selection.isUnspecified) { - return null; - } - for (final provider in configuredSingleAgentProviders) { - if (provider.providerId == selection.providerId) { - return provider; - } - } - return null; - } - List get aiGatewayConversationModelChoices { final availableModels = settingsControllerInternal.effectiveAiGatewayAvailableModels; diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index 1020d640..48ed8d44 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -97,8 +97,9 @@ Future refreshSingleAgentCapabilitiesRuntimeInternal( target: AssistantExecutionTarget.singleAgent, forceRefresh: forceRefresh, ); - controller.bridgeAdvertisedProvidersInternal = - normalizeSingleAgentProviderList(capabilities.providerCatalog); + controller.bridgeProviderCatalogInternal = normalizeSingleAgentProviderList( + capabilities.providerCatalog, + ); if (!controller.disposedInternal) { controller.notifyListeners(); } diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index f9fbc6b3..b70b2417 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -230,8 +230,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController { lowered.contains('missing acp endpoint')) && target == AssistantExecutionTarget.singleAgent) { return appText( - '当前线程还没有同步到 Bridge Server。请先登录账号并在设置里完成同步后再重试。', - 'This thread does not have a synced bridge server yet. Sign in and complete Settings sync before trying again.', + '当前线程缺少可用的 Bridge Server,暂时无法继续。', + 'This thread does not have an available bridge server yet.', ); } if (lowered.contains('gateway not connected') || @@ -241,20 +241,19 @@ extension AppControllerDesktopRuntimeHelpers on AppController { final selection = singleAgentProviderForSession( sessionsControllerInternal.currentSessionKey, ); - final provider = - advertisedSingleAgentProviderInternal(selection) ?? selection; + final provider = currentSingleAgentResolvedProvider ?? selection; final providerLabel = provider.isUnspecified ? appText('Bridge Provider', 'Bridge Provider') : provider.label; final address = _extractGatewayAddressFromErrorInternal(raw); return address.isEmpty ? appText( - '当前线程的 Bridge Provider($providerLabel)未连接。请先在设置里连接并同步后再重试。', - 'The Bridge Provider for this thread ($providerLabel) is not connected. Connect and sync it from Settings, then try again.', + '当前线程的 Bridge Provider($providerLabel)未连接。请先恢复该 Provider 对应连接后再重试。', + 'The Bridge Provider for this thread ($providerLabel) is offline. Restore that provider connection, then try again.', ) : appText( - '当前线程的 Bridge Provider($providerLabel)未连接:$address。请先在设置里连接并同步后再重试。', - 'The Bridge Provider for this thread ($providerLabel) is not connected: $address. Connect and sync it from Settings, then try again.', + '当前线程的 Bridge Provider($providerLabel)未连接:$address。请先恢复该 Provider 对应连接后再重试。', + 'The Bridge Provider for this thread ($providerLabel) is offline: $address. Restore that provider connection, then try again.', ); } final profile = gatewayProfileForAssistantExecutionTargetInternal(target); @@ -439,9 +438,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { if (normalizedRuntimeMode == snapshot.codeAgentRuntimeMode) { return snapshot; } - return snapshot.copyWith( - codeAgentRuntimeMode: normalizedRuntimeMode, - ); + return snapshot.copyWith(codeAgentRuntimeMode: normalizedRuntimeMode); } Future refreshAcpCapabilitiesInternal({ @@ -700,26 +697,31 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return resolveBridgeAcpEndpointInternal(); } + String? runtimeEnvironmentValueInternal(String key) { + final override = environmentOverrideInternal?[key]?.trim() ?? ''; + if (override.isNotEmpty) { + return override; + } + final value = Platform.environment[key]?.trim() ?? ''; + return value.isEmpty ? null : value; + } + Uri? resolveBridgeAcpEndpointInternal() { final endpoint = - settingsControllerInternal - .accountSyncState - ?.syncedDefaults - .bridgeServerUrl - .trim() - .isNotEmpty == - true - ? settingsControllerInternal - .accountSyncState! - .syncedDefaults - .bridgeServerUrl - .trim() - : settings - .acpBridgeServerModeConfig - .cloudSynced - .remoteServerSummary - .endpoint - .trim(); + runtimeEnvironmentValueInternal('BRIDGE_SERVER_URL') ?? + (() { + final synced = + settingsControllerInternal + .accountSyncState + ?.syncedDefaults + .bridgeServerUrl + .trim() ?? + ''; + return synced.isEmpty ? null : synced; + })(); + if (endpoint == null) { + return null; + } final uri = Uri.tryParse(endpoint); final scheme = uri?.scheme.trim().toLowerCase() ?? ''; if (uri == null || !kSupportedExternalAcpEndpointSchemes.contains(scheme)) { @@ -757,12 +759,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController { (bridgePort <= 0 || endpoint.port == bridgePort); if (matchesBridgeEndpoint) { final bridgeToken = + runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN') ?? + runtimeEnvironmentValueInternal('INTERNAL_SERVICE_TOKEN') ?? (await storeInternal.loadAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, - ))?.trim() ?? - ''; - if (bridgeToken.isNotEmpty) { - return 'Bearer $bridgeToken'; + ))?.trim(); + final normalizedToken = bridgeToken?.trim() ?? ''; + if (normalizedToken.isNotEmpty) { + return 'Bearer $normalizedToken'; } } return null; diff --git a/lib/app/app_controller_desktop_settings_runtime.dart b/lib/app/app_controller_desktop_settings_runtime.dart index 5fd318fd..0771d527 100644 --- a/lib/app/app_controller_desktop_settings_runtime.dart +++ b/lib/app/app_controller_desktop_settings_runtime.dart @@ -845,6 +845,7 @@ extension AppControllerDesktopSettingsRuntime on AppController { executionTargetSource: ThreadSelectionSource.explicit, gatewayEntryState: gatewayEntryStateForTargetInternal(target), latestResolvedRuntimeModel: '', + latestResolvedProviderId: '', updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); recomputeTasksInternal(); diff --git a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart index 62e01f9e..e767100b 100644 --- a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart +++ b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart @@ -105,15 +105,18 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( workingDirectory: preflightWorkingDirectory, routing: routing, ); - final resolvedProvider = SingleAgentProviderCopy.fromJsonValue( - routingResolution.resolvedProviderId, + final resolvedProviderId = routingResolution.resolvedProviderId.trim(); + final resolvedProvider = resolvedProviderId.isEmpty + ? null + : controller.bridgeProviderForId(resolvedProviderId) ?? + SingleAgentProviderCopy.fromJsonValue(resolvedProviderId); + final effectiveProvider = resolvedProvider ?? selection; + controller.upsertTaskThreadInternal( + sessionKey, + latestResolvedProviderId: resolvedProviderId, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); - final effectiveProvider = !resolvedProvider.isUnspecified - ? resolvedProvider - : controller.advertisedSingleAgentProviderInternal(selection) ?? - selection; - final unavailableReason = - routingResolution.unavailable + final unavailableReason = routingResolution.unavailable ? singleAgentUnavailableLabelDesktopInternal( controller, sessionKey, @@ -265,6 +268,7 @@ Future _applySingleAgentGoTaskResultDesktopInternal( sessionKey, gatewayEntryState: resolvedGatewayEntryState, latestResolvedRuntimeModel: resolvedRuntimeModel, + latestResolvedProviderId: result.resolvedProviderId, lifecycleStatus: 'ready', lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), lastResultCode: result.success ? 'success' : 'error', diff --git a/lib/app/app_controller_desktop_single_agent_status_messages.dart b/lib/app/app_controller_desktop_single_agent_status_messages.dart index f86493bd..39da6f26 100644 --- a/lib/app/app_controller_desktop_single_agent_status_messages.dart +++ b/lib/app/app_controller_desktop_single_agent_status_messages.dart @@ -75,9 +75,9 @@ String singleAgentUnavailableLabelDesktopInternal( sessionKey, ); final detail = reason?.trim() ?? ''; - final selection = controller.singleAgentProviderForSession( - normalizedSessionKey, - ); + final selection = + controller.currentSingleAgentResolvedProvider ?? + controller.singleAgentProviderForSession(normalizedSessionKey); if (controller.singleAgentShouldSuggestAcpSwitchForSession( normalizedSessionKey, )) { diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index 08dc23b1..75ba819d 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -239,6 +239,7 @@ extension AppControllerDesktopSkillPermissions on AppController { ThreadSelectionSource? selectedSkillsSource, String? gatewayEntryState, String? latestResolvedRuntimeModel, + String? latestResolvedProviderId, String? lifecycleStatus, double? lastRunAtMs, String? lastResultCode, @@ -348,6 +349,7 @@ extension AppControllerDesktopSkillPermissions on AppController { permissionLevel: AssistantPermissionLevel.defaultAccess, messageViewMode: AssistantMessageViewMode.rendered, latestResolvedRuntimeModel: '', + latestResolvedProviderId: '', gatewayEntryState: gatewayEntryStateForTargetInternal( nextExecutionTarget, ), @@ -372,6 +374,7 @@ extension AppControllerDesktopSkillPermissions on AppController { selectedSkillsSource ?? existing?.contextState.selectedSkillsSource, latestResolvedRuntimeModel: latestResolvedRuntimeModel, + latestResolvedProviderId: latestResolvedProviderId, gatewayEntryState: gatewayEntryState, lastRemoteWorkingDirectory: lastRemoteWorkingDirectory, lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind, diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index 78d6c0bb..578288d2 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -48,12 +48,12 @@ import 'app_controller_desktop_runtime_helpers.dart'; class DesktopThreadBindingSnapshotInternal { const DesktopThreadBindingSnapshotInternal({ required this.executionTarget, - required this.singleAgentProvider, + required this.selectedSingleAgentProvider, required this.record, }); final AssistantExecutionTarget executionTarget; - final SingleAgentProvider singleAgentProvider; + final SingleAgentProvider selectedSingleAgentProvider; final TaskThread? record; } @@ -70,12 +70,12 @@ resolveDesktopThreadBindingSnapshotInternal({ : assistantExecutionTargetFromExecutionMode( latestRecord.executionBinding.executionMode, )); - final resolvedProvider = SingleAgentProviderCopy.fromJsonValue( + final selectedProvider = SingleAgentProviderCopy.fromJsonValue( latestRecord?.executionBinding.providerId ?? '', ); return DesktopThreadBindingSnapshotInternal( executionTarget: resolvedExecutionTarget, - singleAgentProvider: resolvedProvider, + selectedSingleAgentProvider: selectedProvider, record: latestRecord, ); } @@ -254,7 +254,8 @@ extension AppControllerDesktopThreadBinding on AppController { required SingleAgentProvider singleAgentProvider, ExecutionBinding? existingBinding, }) { - final providerId = executionTarget == AssistantExecutionTarget.singleAgent + final selectedProviderId = + executionTarget == AssistantExecutionTarget.singleAgent ? settings .sanitizeSingleAgentProviderSelection(singleAgentProvider) .providerId @@ -262,8 +263,8 @@ extension AppControllerDesktopThreadBinding on AppController { return (existingBinding ?? ExecutionBinding( executionMode: ThreadExecutionMode.localAgent, - executorId: providerId, - providerId: providerId, + executorId: selectedProviderId, + providerId: selectedProviderId, endpointId: '', )) .copyWith( @@ -272,8 +273,8 @@ extension AppControllerDesktopThreadBinding on AppController { ThreadExecutionMode.localAgent, AssistantExecutionTarget.gateway => ThreadExecutionMode.gateway, }, - executorId: providerId, - providerId: providerId, + executorId: selectedProviderId, + providerId: selectedProviderId, providerSource: executionTarget == AssistantExecutionTarget.singleAgent ? existingBinding?.providerSource @@ -309,9 +310,7 @@ extension AppControllerDesktopThreadBinding on AppController { workspaceBinding: workspaceBinding, executionBinding: buildDesktopExecutionBindingInternal( executionTarget: snapshot.executionTarget, - singleAgentProvider: settings.sanitizeSingleAgentProviderSelection( - snapshot.singleAgentProvider, - ), + singleAgentProvider: snapshot.selectedSingleAgentProvider, existingBinding: snapshot.record?.executionBinding, ), lifecycleState: diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index bd4d1b7e..c5b7bc89 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -246,15 +246,14 @@ extension AppControllerDesktopThreadSessions on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); - final stored = SingleAgentProviderCopy.fromJsonValue( + final selectedProvider = SingleAgentProviderCopy.fromJsonValue( taskThreadForSessionInternal( normalizedSessionKey, )?.executionBinding.providerId ?? '', ); - final sanitized = settings.sanitizeSingleAgentProviderSelection(stored); - if (!sanitized.isUnspecified) { - return sanitized; + if (!selectedProvider.isUnspecified) { + return selectedProvider; } final options = singleAgentProviderOptions; return options.isEmpty ? SingleAgentProvider.unspecified : options.first; @@ -269,14 +268,32 @@ extension AppControllerDesktopThreadSessions on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); - return advertisedSingleAgentProviderInternal( - singleAgentProviderForSession(normalizedSessionKey), - ); + final record = taskThreadForSessionInternal(normalizedSessionKey); + final resolvedProviderId = record?.latestResolvedProviderId.trim() ?? ''; + if (resolvedProviderId.isNotEmpty) { + return bridgeProviderForId(resolvedProviderId) ?? + SingleAgentProviderCopy.fromJsonValue(resolvedProviderId); + } + return null; } SingleAgentProvider? get currentSingleAgentResolvedProvider => singleAgentResolvedProviderForSession(currentSessionKey); + SingleAgentProvider? singleAgentCatalogProviderForSession(String sessionKey) { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + final selection = singleAgentProviderForSession(normalizedSessionKey); + if (selection.isUnspecified) { + return null; + } + return bridgeProviderForId(selection.providerId); + } + + SingleAgentProvider? get currentSingleAgentCatalogProvider => + singleAgentCatalogProviderForSession(currentSessionKey); + bool singleAgentNeedsBridgeProviderForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, @@ -285,19 +302,12 @@ extension AppControllerDesktopThreadSessions on AppController { AssistantExecutionTarget.singleAgent) { return false; } - return configuredSingleAgentProviders.isEmpty; + return bridgeProviderCatalog.isEmpty; } bool get currentSingleAgentNeedsBridgeProvider => singleAgentNeedsBridgeProviderForSession(currentSessionKey); - bool singleAgentHasResolvedProviderForSession(String sessionKey) { - return singleAgentResolvedProviderForSession(sessionKey) != null; - } - - bool get currentSingleAgentHasResolvedProvider => - singleAgentHasResolvedProviderForSession(currentSessionKey); - bool singleAgentShouldSuggestAcpSwitchForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, @@ -310,8 +320,8 @@ extension AppControllerDesktopThreadSessions on AppController { if (selection.isUnspecified) { return false; } - return !isBridgeAdvertisedSingleAgentProviderInternal(selection) && - configuredSingleAgentProviders.isNotEmpty; + final selectedProvider = bridgeProviderForId(selection.providerId); + return selectedProvider == null && bridgeProviderCatalog.isNotEmpty; } bool get currentSingleAgentShouldSuggestAcpSwitch => @@ -346,6 +356,7 @@ extension AppControllerDesktopThreadSessions on AppController { } final provider = singleAgentResolvedProviderForSession(normalizedSessionKey) ?? + singleAgentCatalogProviderForSession(normalizedSessionKey) ?? singleAgentProviderForSession(normalizedSessionKey); return appText( '请先配置 ${provider.label} 模型', @@ -371,11 +382,7 @@ extension AppControllerDesktopThreadSessions on AppController { singleAgentShouldShowModelControlForSession(currentSessionKey); List get singleAgentProviderOptions => - configuredSingleAgentProviders; - - String singleAgentProviderLabelForSession(String sessionKey) { - return singleAgentProviderForSession(sessionKey).label; - } + bridgeProviderCatalog; String get assistantConversationOwnerLabel { if (!isSingleAgentMode) { @@ -385,6 +392,10 @@ extension AppControllerDesktopThreadSessions on AppController { if (resolvedProvider != null) { return resolvedProvider.label; } + final catalogProvider = currentSingleAgentCatalogProvider; + if (catalogProvider != null) { + return catalogProvider.label; + } final provider = currentSingleAgentProvider; if (!provider.isUnspecified) { return provider.label; @@ -408,18 +419,20 @@ extension AppControllerDesktopThreadSessions on AppController { final resolvedProvider = singleAgentResolvedProviderForSession( normalizedSessionKey, ); + final catalogProvider = singleAgentCatalogProviderForSession( + normalizedSessionKey, + ); final model = assistantModelForSession(normalizedSessionKey); - final providerReady = resolvedProvider != null; + final providerReady = catalogProvider != null; + final displayProvider = resolvedProvider ?? catalogProvider ?? provider; final detail = providerReady - ? joinConnectionPartsInternal([resolvedProvider.label, model]) + ? joinConnectionPartsInternal([displayProvider.label, model]) : singleAgentShouldSuggestAcpSwitchForSession(normalizedSessionKey) ? appText( '${provider.label} 当前不可用,请改成 Bridge 当前可用的 Provider。', '${provider.label} is unavailable. Switch to a provider currently advertised by the bridge.', ) - : singleAgentNeedsBridgeProviderForSession( - normalizedSessionKey, - ) + : singleAgentNeedsBridgeProviderForSession(normalizedSessionKey) ? appText( 'Bridge 当前没有可用 Provider。', 'The bridge does not currently advertise any available providers.', diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index 39ce1164..73235f25 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -693,10 +693,8 @@ extension AppControllerDesktopThreadStorage on AppController { ); final recordProvider = recordExecutionTarget == AssistantExecutionTarget.singleAgent - ? settings.sanitizeSingleAgentProviderSelection( - SingleAgentProviderCopy.fromJsonValue( - record.executionBinding.providerId, - ), + ? SingleAgentProviderCopy.fromJsonValue( + record.executionBinding.providerId, ) : const SingleAgentProvider( providerId: kCanonicalGatewayProviderId, diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart index 53b8d9b1..4492689e 100644 --- a/lib/app/app_controller_desktop_workspace_execution.dart +++ b/lib/app/app_controller_desktop_workspace_execution.dart @@ -86,6 +86,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController { executionTargetSource: ThreadSelectionSource.explicit, gatewayEntryState: gatewayEntryStateForTargetInternal(resolvedTarget), latestResolvedRuntimeModel: '', + latestResolvedProviderId: '', updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); recomputeTasksInternal(); @@ -128,6 +129,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController { singleAgentProvider: sanitizedProvider, singleAgentProviderSource: ThreadSelectionSource.explicit, latestResolvedRuntimeModel: '', + latestResolvedProviderId: '', updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); recomputeTasksInternal(); @@ -196,6 +198,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController { upsertTaskThreadInternal( normalizedSessionKey, latestResolvedRuntimeModel: '', + latestResolvedProviderId: '', updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); } diff --git a/lib/features/assistant/assistant_page_components.dart b/lib/features/assistant/assistant_page_components.dart index 07775259..1f46e252 100644 --- a/lib/features/assistant/assistant_page_components.dart +++ b/lib/features/assistant/assistant_page_components.dart @@ -81,11 +81,12 @@ class AssistantTaskRailStateInternal extends State { final tasks = widget.tasks; final groupedTasks = groupTasksForRailInternal( tasks, - widget.controller - .visibleAssistantExecutionTargets(const [ - AssistantExecutionTarget.singleAgent, - AssistantExecutionTarget.gateway, - ]), + widget.controller.visibleAssistantExecutionTargets( + const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.gateway, + ], + ), ); final runningCount = tasks .where((task) => normalizedTaskStatusInternal(task.status) == 'running') @@ -506,19 +507,22 @@ class AssistantEmptyStateInternal extends StatelessWidget { controller.currentSingleAgentNeedsBridgeProvider; final singleAgentSuggestsAcpSwitch = controller.currentSingleAgentShouldSuggestAcpSwitch; - final providerLabel = controller.currentSingleAgentProvider.label; + final providerLabel = + (controller.currentSingleAgentResolvedProvider ?? + controller.currentSingleAgentProvider) + .label; final reconnectAvailable = controller.canQuickConnectGateway; final title = singleAgent ? connected ? appText('开始智能体任务', 'Start an agent task') : singleAgentNeedsBridgeProvider ? appText( - '先配置 Bridge Provider', - 'Configure a bridge provider first', + '等待 Bridge Provider', + 'Waiting for a bridge provider', ) : appText( - '先准备 Bridge Provider', - 'Prepare the bridge provider first', + '等待 Bridge 就绪', + 'Waiting for bridge readiness', ) : connected ? appText('开始对话或运行任务', 'Start a chat or run a task') @@ -538,12 +542,12 @@ class AssistantEmptyStateInternal extends StatelessWidget { ) : singleAgentNeedsBridgeProvider ? appText( - '请先在 设置 -> 集成 中配置并同步可用的外部 Agent 连接,然后再继续当前任务。', - 'Configure and sync an available external agent connection in Settings -> Integrations before continuing this task.', + 'Bridge 当前没有广告可用 Provider。恢复后可直接开始任务;当前流程不依赖本地集成配置。', + 'The bridge is not advertising any available providers right now. Once it recovers, this thread can start directly without extra local integration setup.', ) : appText( - '当前线程的 Bridge Provider 尚未就绪。请先检查 $providerLabel 对应连接。', - 'The bridge provider for this thread is not ready yet. Check the connection mapped to $providerLabel first.', + '当前线程的 Bridge Provider 尚未就绪。请等待 Bridge 恢复,或切换到当前可用 Provider。', + 'The bridge provider for this thread is not ready yet. Wait for the bridge to recover, or switch to a currently available provider.', ) : connected ? appText( @@ -602,9 +606,7 @@ class AssistantEmptyStateInternal extends StatelessWidget { onPressed: connected ? onFocusComposer : singleAgent - ? singleAgentNeedsBridgeProvider - ? onOpenAiGatewaySettings - : onFocusComposer + ? onFocusComposer : reconnectAvailable ? () async { await onReconnectGateway(); @@ -614,9 +616,7 @@ class AssistantEmptyStateInternal extends StatelessWidget { connected ? Icons.edit_rounded : singleAgent - ? singleAgentNeedsBridgeProvider - ? Icons.tune_rounded - : Icons.smart_toy_outlined + ? Icons.smart_toy_outlined : reconnectAvailable ? Icons.refresh_rounded : Icons.link_rounded, @@ -625,9 +625,7 @@ class AssistantEmptyStateInternal extends StatelessWidget { connected ? appText('开始输入', 'Start typing') : singleAgent - ? singleAgentNeedsBridgeProvider - ? appText('打开配置中心', 'Open settings') - : appText('查看线程工具栏', 'Open toolbar') + ? appText('查看线程工具栏', 'Open toolbar') : reconnectAvailable ? appText('重新连接', 'Reconnect') : appText('连接 Gateway', 'Connect gateway'), @@ -643,8 +641,7 @@ class AssistantEmptyStateInternal extends StatelessWidget { ), ), ), - if (!connected && - (!singleAgent || singleAgentNeedsBridgeProvider)) + if (!connected && !singleAgent) OutlinedButton.icon( onPressed: singleAgent ? onOpenAiGatewaySettings diff --git a/lib/features/assistant/assistant_page_composer_bar.dart b/lib/features/assistant/assistant_page_composer_bar.dart index 5aa4e37f..d51558c4 100644 --- a/lib/features/assistant/assistant_page_composer_bar.dart +++ b/lib/features/assistant/assistant_page_composer_bar.dart @@ -374,6 +374,9 @@ class ComposerBarStateInternal extends State { final selectedSkills = widget.availableSkills .where((skill) => widget.selectedSkillKeys.contains(skill.key)) .toList(growable: false); + final displayedSingleAgentProvider = + controller.currentSingleAgentResolvedProvider ?? + controller.currentSingleAgentProvider; final submitLabel = connected ? appText('提交', 'Submit') : singleAgent @@ -500,10 +503,10 @@ class ComposerBarStateInternal extends State { .toList(), child: ComposerToolbarChipInternal( leading: SingleAgentProviderBadgeInternal( - provider: controller.currentSingleAgentProvider, + provider: displayedSingleAgentProvider, ), tooltip: singleAgentProviderTooltipInternal( - controller.currentSingleAgentProvider, + displayedSingleAgentProvider, ), showChevron: true, padding: const EdgeInsets.symmetric( diff --git a/lib/features/modules/modules_page.dart b/lib/features/modules/modules_page.dart index 8e1dd4fb..892da95c 100644 --- a/lib/features/modules/modules_page.dart +++ b/lib/features/modules/modules_page.dart @@ -663,7 +663,7 @@ class _SkillsPanel extends StatelessWidget { ? StatusInfo(appText('当前模式', 'Current mode'), StatusTone.accent) : StatusInfo(appText('可切换', 'Available'), StatusTone.success), chips: [ - for (final provider in controller.configuredSingleAgentProviders) + for (final provider in controller.bridgeProviderCatalog) provider.label, ], skills: singleAgentSkills.map((item) => item.name).toList(), diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 6285a00f..9c04d925 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -753,6 +753,7 @@ class ThreadContextState { required this.permissionLevel, required this.messageViewMode, required this.latestResolvedRuntimeModel, + required this.latestResolvedProviderId, this.selectedModelSource = ThreadSelectionSource.inherited, this.selectedSkillsSource = ThreadSelectionSource.inherited, this.gatewayEntryState, @@ -769,6 +770,7 @@ class ThreadContextState { final AssistantPermissionLevel permissionLevel; final AssistantMessageViewMode messageViewMode; final String latestResolvedRuntimeModel; + final String latestResolvedProviderId; final ThreadSelectionSource selectedModelSource; final ThreadSelectionSource selectedSkillsSource; final String? gatewayEntryState; @@ -785,6 +787,7 @@ class ThreadContextState { AssistantPermissionLevel? permissionLevel, AssistantMessageViewMode? messageViewMode, String? latestResolvedRuntimeModel, + String? latestResolvedProviderId, ThreadSelectionSource? selectedModelSource, ThreadSelectionSource? selectedSkillsSource, String? gatewayEntryState, @@ -803,6 +806,8 @@ class ThreadContextState { messageViewMode: messageViewMode ?? this.messageViewMode, latestResolvedRuntimeModel: latestResolvedRuntimeModel ?? this.latestResolvedRuntimeModel, + latestResolvedProviderId: + latestResolvedProviderId ?? this.latestResolvedProviderId, selectedModelSource: selectedModelSource ?? this.selectedModelSource, selectedSkillsSource: selectedSkillsSource ?? this.selectedSkillsSource, gatewayEntryState: clearGatewayEntryState @@ -829,6 +834,7 @@ class ThreadContextState { 'permissionLevel': permissionLevel.name, 'messageViewMode': messageViewMode.name, 'latestResolvedRuntimeModel': latestResolvedRuntimeModel, + 'latestResolvedProviderId': latestResolvedProviderId, 'selectedModelSource': selectedModelSource.name, 'selectedSkillsSource': selectedSkillsSource.name, 'gatewayEntryState': gatewayEntryState, @@ -890,6 +896,8 @@ class ThreadContextState { ), latestResolvedRuntimeModel: json['latestResolvedRuntimeModel']?.toString() ?? '', + latestResolvedProviderId: + json['latestResolvedProviderId']?.toString() ?? '', selectedModelSource: ThreadSelectionSourceCopy.fromJsonValue( json['selectedModelSource']?.toString(), ), @@ -989,6 +997,7 @@ class TaskThread { String? gatewayEntryState, AssistantPermissionLevel? permissionLevel, String? latestResolvedRuntimeModel, + String? latestResolvedProviderId, double? lastRunAtMs, String? lastResultCode, String? lastRemoteWorkingDirectory, @@ -1028,6 +1037,7 @@ class TaskThread { messageViewMode ?? AssistantMessageViewMode.rendered, latestResolvedRuntimeModel: latestResolvedRuntimeModel?.trim() ?? '', + latestResolvedProviderId: latestResolvedProviderId?.trim() ?? '', gatewayEntryState: gatewayEntryState?.trim(), lastRemoteWorkingDirectory: lastRemoteWorkingDirectory?.trim().isNotEmpty == true @@ -1079,6 +1089,7 @@ class TaskThread { String? get lastArtifactSyncStatus => contextState.lastArtifactSyncStatus; String get latestResolvedRuntimeModel => contextState.latestResolvedRuntimeModel; + String get latestResolvedProviderId => contextState.latestResolvedProviderId; bool get hasExplicitExecutionTargetSelection => executionBinding.executionModeSource == ThreadSelectionSource.explicit; bool get hasExplicitProviderSelection => @@ -1113,6 +1124,7 @@ class TaskThread { String? gatewayEntryState, bool clearGatewayEntryState = false, String? latestResolvedRuntimeModel, + String? latestResolvedProviderId, String? lastRemoteWorkingDirectory, WorkspaceRefKind? lastRemoteWorkspaceRefKind, double? lastArtifactSyncAtMs, @@ -1133,6 +1145,7 @@ class TaskThread { selectedModelSource: assistantModelSource, selectedSkillsSource: selectedSkillsSource, latestResolvedRuntimeModel: latestResolvedRuntimeModel, + latestResolvedProviderId: latestResolvedProviderId, gatewayEntryState: gatewayEntryState, clearGatewayEntryState: clearGatewayEntryState, lastRemoteWorkingDirectory: lastRemoteWorkingDirectory, @@ -1247,6 +1260,7 @@ class TaskThread { 'permissionLevel': json['permissionLevel'], 'messageViewMode': json['messageViewMode'], 'latestResolvedRuntimeModel': json['latestResolvedRuntimeModel'], + 'latestResolvedProviderId': json['latestResolvedProviderId'], 'selectedModelSource': json['assistantModelSource'], 'selectedSkillsSource': json['selectedSkillsSource'], 'gatewayEntryState': json['gatewayEntryState'], diff --git a/lib/runtime/runtime_models_settings_snapshot.dart b/lib/runtime/runtime_models_settings_snapshot.dart index 59b996e1..beadb522 100644 --- a/lib/runtime/runtime_models_settings_snapshot.dart +++ b/lib/runtime/runtime_models_settings_snapshot.dart @@ -392,12 +392,6 @@ class SettingsSnapshot { SingleAgentProvider sanitizeSingleAgentProviderSelection( SingleAgentProvider provider, ) { - if (provider.isUnspecified) { - return SingleAgentProvider.unspecified; - } - if (isBridgeOwnedSingleAgentProviderId(provider.providerId)) { - return provider; - } - return SingleAgentProvider.unspecified; + return provider.isUnspecified ? SingleAgentProvider.unspecified : provider; } } diff --git a/pubspec.lock b/pubspec.lock index d65384b9..3b8f4dd8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -287,14 +287,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" - golden_toolkit: - dependency: "direct dev" - description: - name: golden_toolkit - sha256: "8f74adab33154fe7b731395782797021f97d2edc52f7bfb85ff4f1b5c4a215f0" - url: "https://pub.dev" - source: hosted - version: "0.15.0" hooks: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ce476c54..8f20b8a6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,7 +37,6 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter - golden_toolkit: ^0.15.0 patrol: ^4.3.0 flutter_lints: ^6.0.0 diff --git a/test/app_controller_desktop_runtime_cleanup_test.dart b/test/app_controller_desktop_runtime_cleanup_test.dart index 0bed7363..4ec0cf6a 100644 --- a/test/app_controller_desktop_runtime_cleanup_test.dart +++ b/test/app_controller_desktop_runtime_cleanup_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller_desktop_core.dart'; import 'package:xworkmate/app/app_controller_desktop_skill_permissions.dart'; import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart'; +import 'package:xworkmate/app/app_controller_desktop_thread_storage.dart'; import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart'; import 'package:xworkmate/runtime/desktop_platform_service.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; @@ -62,8 +63,9 @@ void main() { ); } - final settingsSnapshot = File('lib/runtime/runtime_models_settings_snapshot.dart') - .readAsStringSync(); + final settingsSnapshot = File( + 'lib/runtime/runtime_models_settings_snapshot.dart', + ).readAsStringSync(); expect( settingsSnapshot.contains('providerSyncDefinitions'), isFalse, @@ -76,8 +78,9 @@ void main() { reason: 'settings snapshots should not persist app-side Codex CLI paths', ); - final accountModels = File('lib/runtime/runtime_models_account.dart') - .readAsStringSync(); + final accountModels = File( + 'lib/runtime/runtime_models_account.dart', + ).readAsStringSync(); expect( accountModels.contains('acpBridgeServerProfiles'), isFalse, @@ -85,8 +88,9 @@ void main() { 'account advanced overrides should not mirror bridge provider catalogs', ); - final orchestrator = File('lib/runtime/code_agent_node_orchestrator.dart') - .readAsStringSync(); + final orchestrator = File( + 'lib/runtime/code_agent_node_orchestrator.dart', + ).readAsStringSync(); expect( orchestrator.contains('configuredCodexCliPath'), isFalse, @@ -287,13 +291,131 @@ void main() { expect(thread!.hasExplicitProviderSelection, isFalse); }, ); + + group('thread restore provider semantics', () { + const owner = ThreadOwnerScope( + realm: ThreadRealm.local, + subjectType: ThreadSubjectType.user, + subjectId: 'u1', + displayName: 'User', + ); + + TaskThread buildThread({ + required String threadId, + required ThreadExecutionMode mode, + required String providerId, + String latestResolvedProviderId = '', + }) { + return TaskThread( + threadId: threadId, + ownerScope: owner, + workspaceBinding: const WorkspaceBinding( + workspaceId: 'ws-1', + workspaceKind: WorkspaceKind.localFs, + workspacePath: '/tmp/ws', + displayPath: '/tmp/ws', + writable: true, + ), + executionBinding: ExecutionBinding( + executionMode: mode, + executorId: providerId, + providerId: providerId, + endpointId: '', + ), + latestResolvedProviderId: latestResolvedProviderId, + ); + } + + test( + 'restore preserves the stored single-agent provider selection without inventing a resolved provider', + () { + final controller = AppController(); + _seedBridgeProviders(controller, const [ + SingleAgentProvider.codex, + ]); + addTearDown(controller.dispose); + + const sessionKey = 'draft:restore-selection'; + controller.restoreAssistantThreadsInternal([ + buildThread( + threadId: sessionKey, + mode: ThreadExecutionMode.localAgent, + providerId: 'legacy-provider', + ), + ]); + + final restored = controller.requireTaskThreadForSessionInternal( + sessionKey, + ); + expect(restored.executionBinding.providerId, 'legacy-provider'); + expect( + controller.singleAgentProviderForSession(sessionKey).providerId, + 'legacy-provider', + ); + expect( + controller.singleAgentResolvedProviderForSession(sessionKey), + isNull, + ); + }, + ); + + test( + 'restore continues to treat latestResolvedProviderId as the only resolved provider source', + () { + final controller = AppController(); + _seedBridgeProviders(controller, const [ + SingleAgentProvider.codex, + ]); + addTearDown(controller.dispose); + + const sessionKey = 'draft:restore-resolved-provider'; + controller.restoreAssistantThreadsInternal([ + buildThread( + threadId: sessionKey, + mode: ThreadExecutionMode.localAgent, + providerId: 'legacy-provider', + latestResolvedProviderId: SingleAgentProvider.codex.providerId, + ), + ]); + + expect( + controller.singleAgentProviderForSession(sessionKey).providerId, + 'legacy-provider', + ); + expect( + controller.singleAgentResolvedProviderForSession(sessionKey), + SingleAgentProvider.codex, + ); + }, + ); + + test('restore still canonicalizes gateway provider bindings', () { + final controller = AppController(); + addTearDown(controller.dispose); + + const sessionKey = 'draft:restore-gateway'; + controller.restoreAssistantThreadsInternal([ + buildThread( + threadId: sessionKey, + mode: ThreadExecutionMode.gateway, + providerId: 'legacy-provider', + ), + ]); + + final restored = controller.requireTaskThreadForSessionInternal( + sessionKey, + ); + expect(restored.executionBinding.providerId, kCanonicalGatewayProviderId); + expect(restored.executionBinding.executorId, kCanonicalGatewayProviderId); + }); + }); } void _seedBridgeProviders( AppController controller, List providers, ) { - controller.bridgeAdvertisedProvidersInternal = providers; + controller.bridgeProviderCatalogInternal = providers; } class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService { diff --git a/test/app_controller_desktop_thread_binding_test.dart b/test/app_controller_desktop_thread_binding_test.dart index b468b2fc..b889e886 100644 --- a/test/app_controller_desktop_thread_binding_test.dart +++ b/test/app_controller_desktop_thread_binding_test.dart @@ -8,14 +8,12 @@ import 'package:xworkmate/app/app_controller_desktop_skill_permissions.dart'; import 'package:xworkmate/app/app_controller_desktop_thread_binding.dart'; import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart'; import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart'; -import 'package:xworkmate/runtime/account_runtime_client.dart'; import 'package:xworkmate/runtime/codex_config_bridge.dart'; import 'package:xworkmate/runtime/codex_runtime.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; -import 'package:xworkmate/runtime/runtime_controllers.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; @@ -34,6 +32,7 @@ void main() { required String threadId, required ThreadExecutionMode mode, required String providerId, + String latestResolvedProviderId = '', }) { return TaskThread( threadId: threadId, @@ -51,6 +50,7 @@ void main() { providerId: providerId, endpointId: '', ), + latestResolvedProviderId: latestResolvedProviderId, ); } @@ -67,7 +67,10 @@ void main() { ); expect(snapshot.executionTarget, AssistantExecutionTarget.singleAgent); - expect(snapshot.singleAgentProvider, SingleAgentProvider.opencode); + expect( + snapshot.selectedSingleAgentProvider, + SingleAgentProvider.opencode, + ); expect(snapshot.record, same(latestRecord)); }); @@ -87,7 +90,37 @@ void main() { ); expect(snapshot.executionTarget, AssistantExecutionTarget.gateway); - expect(snapshot.singleAgentProvider, SingleAgentProvider.opencode); + expect( + snapshot.selectedSingleAgentProvider, + SingleAgentProvider.opencode, + ); + }, + ); + + test( + 'keeps the stored provider selection separate from resolved provider', + () { + final latestRecord = buildThread( + threadId: 'thread-2b', + mode: ThreadExecutionMode.localAgent, + providerId: SingleAgentProvider.opencode.providerId, + latestResolvedProviderId: SingleAgentProvider.codex.providerId, + ); + + final snapshot = resolveDesktopThreadBindingSnapshotInternal( + defaultExecutionTarget: AssistantExecutionTarget.gateway, + latestRecord: latestRecord, + ); + + expect(snapshot.executionTarget, AssistantExecutionTarget.singleAgent); + expect( + snapshot.selectedSingleAgentProvider, + SingleAgentProvider.opencode, + ); + expect( + latestRecord.latestResolvedProviderId, + SingleAgentProvider.codex.providerId, + ); }, ); @@ -104,7 +137,7 @@ void main() { ); expect(snapshot.executionTarget, AssistantExecutionTarget.gateway); - expect(snapshot.singleAgentProvider.isUnspecified, isTrue); + expect(snapshot.selectedSingleAgentProvider.isUnspecified, isTrue); expect(snapshot.record, isNull); expect(staleRecord.executionBinding.providerId, isNotEmpty); }); @@ -228,6 +261,45 @@ void main() { expect(state.ready, isTrue); }, ); + + test( + 'treats an advertised bridge catalog provider as ready before the first resolved turn', + () { + final controller = AppController(); + addTearDown(controller.dispose); + + const sessionKey = 'draft:single-agent-ready-from-catalog'; + controller.initializeAssistantThreadContext( + sessionKey, + executionTarget: AssistantExecutionTarget.singleAgent, + singleAgentProvider: SingleAgentProvider.codex, + ); + controller.bridgeProviderCatalogInternal = const [ + SingleAgentProvider.codex, + ]; + controller.upsertTaskThreadInternal( + sessionKey, + executionTarget: AssistantExecutionTarget.singleAgent, + executionTargetSource: ThreadSelectionSource.explicit, + singleAgentProvider: SingleAgentProvider.codex, + singleAgentProviderSource: ThreadSelectionSource.explicit, + ); + + expect( + controller.singleAgentResolvedProviderForSession(sessionKey), + isNull, + ); + expect( + controller.singleAgentCatalogProviderForSession(sessionKey), + SingleAgentProvider.codex, + ); + + final state = controller.assistantConnectionStateForSession(sessionKey); + expect(state.status, RuntimeConnectionStatus.connected); + expect(state.ready, isTrue); + expect(state.detailLabel, contains('Codex')); + }, + ); }); group('buildExternalAcpRoutingForSessionInternal', () { @@ -333,17 +405,13 @@ void main() { }); group('resolveGatewayAcpAuthorizationHeaderInternal', () { - test('uses only synced or persisted BRIDGE_SERVER_URL values', () { - final controller = AppController(); - addTearDown(controller.dispose); - - expect(controller.resolveBridgeAcpEndpointInternal(), isNull); - expect( - controller.resolveExternalAcpEndpointForTargetInternal( - AssistantExecutionTarget.singleAgent, - ), - isNull, + test('prefers BRIDGE_SERVER_URL from environment over local settings', () { + final controller = AppController( + environmentOverride: const { + 'BRIDGE_SERVER_URL': 'https://bridge.env.example/acp', + }, ); + addTearDown(controller.dispose); controller.settingsController.snapshotInternal = controller.settings .copyWith( @@ -367,24 +435,51 @@ void main() { expect( controller.resolveBridgeAcpEndpointInternal(), - Uri.parse('https://bridge.customer.example/acp'), + Uri.parse('https://bridge.env.example/acp'), ); expect( controller.resolveExternalAcpEndpointForTargetInternal( AssistantExecutionTarget.singleAgent, ), - Uri.parse('https://bridge.customer.example/acp'), + Uri.parse('https://bridge.env.example/acp'), ); expect( controller.resolveExternalAcpEndpointForTargetInternal( AssistantExecutionTarget.gateway, ), - Uri.parse('https://bridge.customer.example/acp'), + Uri.parse('https://bridge.env.example/acp'), ); }); + test('does not recover bridge endpoint from local settings snapshot alone', () { + final controller = AppController(); + addTearDown(controller.dispose); + + controller.settingsController.snapshotInternal = controller.settings + .copyWith( + acpBridgeServerModeConfig: controller + .settings + .acpBridgeServerModeConfig + .copyWith( + cloudSynced: controller + .settings + .acpBridgeServerModeConfig + .cloudSynced + .copyWith( + remoteServerSummary: + const AcpBridgeServerRemoteServerSummary( + endpoint: 'https://bridge.customer.example/acp', + hasAdvancedOverrides: false, + ), + ), + ), + ); + + expect(controller.resolveBridgeAcpEndpointInternal(), isNull); + }); + test( - 'prefers the synced bridge bearer token over the account session token', + 'prefers environment bridge bearer tokens over persisted bridge secrets', () async { final root = await Directory.systemTemp.createTemp( 'xworkmate-bridge-auth-header-', @@ -397,7 +492,11 @@ void main() { ); final controller = AppController( store: store, - accountClientFactory: (_) => _BridgeSyncAccountRuntimeClient(), + environmentOverride: const { + 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge.svc.plus/acp', + 'BRIDGE_AUTH_TOKEN': 'env-bridge-token', + 'INTERNAL_SERVICE_TOKEN': 'env-internal-token', + }, ); addTearDown(() async { controller.dispose(); @@ -411,22 +510,9 @@ void main() { }); await store.initialize(); - await controller.settingsController.initialize(); - await controller.settingsController.saveSnapshot( - controller.settings.copyWith( - accountBaseUrl: 'https://accounts.svc.plus', - accountUsername: 'review@svc.plus', - ), - ); - await controller.settingsController.loginAccount( - baseUrl: 'https://accounts.svc.plus', - identifier: 'review@svc.plus', - password: '***REMOVED-CREDENTIAL***', - ); - await controller.settingsController.saveGatewaySecrets( - profileIndex: kGatewayRemoteProfileIndex, - token: 'local-token', - password: '', + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'persisted-bridge-token', ); final bridgeAuthorization = await controller @@ -438,7 +524,7 @@ void main() { Uri.parse('https://remote.example.com/acp'), ); - expect(bridgeAuthorization, 'Bearer bridge-token'); + expect(bridgeAuthorization, 'Bearer env-bridge-token'); expect(nonBridgeAuthorization, isNull); }, ); @@ -517,28 +603,3 @@ class _FakeGatewayRuntimeDeps { final SecureConfigStore store; final DeviceIdentityStore identityStore; } - -class _BridgeSyncAccountRuntimeClient extends AccountRuntimeClient { - _BridgeSyncAccountRuntimeClient() - : super(baseUrl: 'https://accounts.svc.plus'); - - @override - Future> login({ - required String identifier, - required String password, - }) async { - return { - 'token': 'session-token', - 'internalServiceToken': 'bridge-token', - 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge.svc.plus', - 'expiresAt': '2026-04-12T00:00:00Z', - 'user': { - 'id': 'u-1', - 'email': identifier, - 'name': 'Review', - 'role': 'member', - 'mfaEnabled': false, - }, - }; - } -} diff --git a/test/app_controller_desktop_working_directory_dispatch_test.dart b/test/app_controller_desktop_working_directory_dispatch_test.dart index 54d694a8..4ba6baaa 100644 --- a/test/app_controller_desktop_working_directory_dispatch_test.dart +++ b/test/app_controller_desktop_working_directory_dispatch_test.dart @@ -216,7 +216,7 @@ void _seedBridgeProviders( AppController controller, List providers, ) { - controller.bridgeAdvertisedProvidersInternal = providers; + controller.bridgeProviderCatalogInternal = providers; } class _CapturingGoTaskServiceClient implements GoTaskServiceClient { diff --git a/test/assistant_execution_target_picker_widget_test.dart b/test/assistant_execution_target_picker_widget_test.dart index 27f0a783..3a723377 100644 --- a/test/assistant_execution_target_picker_widget_test.dart +++ b/test/assistant_execution_target_picker_widget_test.dart @@ -7,6 +7,7 @@ import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart'; import 'package:xworkmate/features/assistant/assistant_page_composer_bar.dart'; import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart'; import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart'; +import 'package:xworkmate/features/assistant/assistant_page_components.dart'; import 'package:xworkmate/runtime/desktop_platform_service.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; @@ -237,13 +238,72 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); }); + + testWidgets( + 'single-agent empty state no longer routes users to Settings -> Integrations', + (tester) async { + final root = Directory.systemTemp.createTempSync( + 'xworkmate-empty-state-widget-test-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => '${root.path}/settings.sqlite3', + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + final controller = AppController( + store: store, + desktopPlatformService: UnsupportedDesktopPlatformService(), + skillDirectoryAccessService: _FakeSkillDirectoryAccessService( + root.path, + ), + goTaskServiceClient: const _FakeGoTaskServiceClient(), + singleAgentSharedSkillScanRootOverrides: const [], + ); + addTearDown(() async { + controller.dispose(); + if (root.existsSync()) { + await root.delete(recursive: true); + } + }); + + controller.initializeAssistantThreadContext( + controller.currentSessionKey, + executionTarget: AssistantExecutionTarget.singleAgent, + singleAgentProvider: SingleAgentProvider.codex, + ); + controller.bridgeProviderCatalogInternal = const []; + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(platform: TargetPlatform.macOS), + home: Scaffold( + body: AssistantEmptyStateInternal( + controller: controller, + onFocusComposer: () {}, + onOpenGateway: () {}, + onOpenAiGatewaySettings: () {}, + onReconnectGateway: () async {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.textContaining('设置 -> 集成'), findsNothing); + expect(find.textContaining('本地集成配置'), findsOneWidget); + expect(find.text('打开配置中心'), findsNothing); + expect(find.text('打开设置中心'), findsNothing); + expect(find.text('查看线程工具栏'), findsOneWidget); + }, + ); } void _seedBridgeProviders( AppController controller, List providers, ) { - controller.bridgeAdvertisedProvidersInternal = providers; + controller.bridgeProviderCatalogInternal = providers; } class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService { diff --git a/test/features/assistant/assistant_page_composer_golden_test.dart b/test/features/assistant/assistant_page_composer_golden_test.dart deleted file mode 100644 index 5744323b..00000000 --- a/test/features/assistant/assistant_page_composer_golden_test.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:xworkmate/app/app_controller_desktop_core.dart'; -import 'package:xworkmate/features/assistant/assistant_page_composer_bar.dart'; -import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart'; -import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart'; -import 'package:xworkmate/runtime/desktop_platform_service.dart'; -import 'package:xworkmate/runtime/go_task_service_client.dart'; -import 'package:xworkmate/runtime/runtime_models.dart'; -import 'package:xworkmate/runtime/secure_config_store.dart'; -import 'package:xworkmate/runtime/skill_directory_access.dart'; -import 'package:xworkmate/theme/app_theme.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets('renders composer with thread provider controls only', ( - tester, - ) async { - await tester.binding.setSurfaceSize(const Size(1400, 320)); - addTearDown(() async => tester.binding.setSurfaceSize(null)); - - final root = Directory.systemTemp.createTempSync( - 'xworkmate-composer-golden-', - ); - final store = SecureConfigStore( - enableSecureStorage: false, - appDataRootPathResolver: () async => '${root.path}/settings.sqlite3', - secretRootPathResolver: () async => root.path, - supportRootPathResolver: () async => root.path, - ); - final controller = AppController( - store: store, - desktopPlatformService: UnsupportedDesktopPlatformService(), - skillDirectoryAccessService: _GoldenSkillDirectoryAccessService( - root.path, - ), - goTaskServiceClient: const _GoldenGoTaskServiceClient(), - singleAgentSharedSkillScanRootOverrides: const [], - ); - _seedBridgeProviders(controller, const [ - SingleAgentProvider.codex, - ]); - final inputController = TextEditingController(text: '请整理今天的任务进展'); - final focusNode = FocusNode(); - - addTearDown(() async { - controller.dispose(); - inputController.dispose(); - focusNode.dispose(); - if (root.existsSync()) { - await root.delete(recursive: true); - } - }); - - controller.appUiStateInternal = controller.appUiState.copyWith( - savedGatewayTargets: const ['gateway'], - ); - controller.lastObservedSettingsSnapshotInternal = - controller.settingsController.snapshotInternal; - - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(platform: TargetPlatform.macOS), - home: Scaffold( - body: Center( - child: RepaintBoundary( - key: const ValueKey('assistant-composer-boundary'), - child: SizedBox( - width: 1280, - child: ComposerBarInternal( - controller: controller, - inputController: inputController, - focusNode: focusNode, - thinkingLabel: 'Normal', - showModelControl: false, - modelLabel: '', - modelOptions: const [], - attachments: const [], - availableSkills: const [], - selectedSkillKeys: const [], - onRemoveAttachment: (_) {}, - onToggleSkill: (_) {}, - onThinkingChanged: (_) {}, - onModelChanged: (_) async {}, - onOpenGateway: () {}, - onOpenAiGatewaySettings: () {}, - onReconnectGateway: () async {}, - onPickAttachments: () {}, - onAddAttachment: (_) {}, - onPasteImageAttachment: () async => null, - onContentHeightChanged: (_) {}, - onInputHeightChanged: (_) {}, - onSend: () async {}, - ), - ), - ), - ), - ), - ), - ); - await tester.pump(const Duration(milliseconds: 300)); - - await expectLater( - find.byKey(const ValueKey('assistant-composer-boundary')), - matchesGoldenFile( - 'goldens/assistant_page_composer_working_directory.png', - ), - ); - }); -} - -void _seedBridgeProviders( - AppController controller, - List providers, -) { - controller.bridgeAdvertisedProvidersInternal = providers; -} - -class _GoldenSkillDirectoryAccessService - implements SkillDirectoryAccessService { - const _GoldenSkillDirectoryAccessService(this.homeDirectory); - - final String homeDirectory; - - @override - bool get isSupported => false; - - @override - Future> authorizeDirectories({ - List suggestedPaths = const [], - }) async { - return const []; - } - - @override - Future authorizeDirectory({ - String suggestedPath = '', - }) async { - return null; - } - - @override - Future openDirectory( - AuthorizedSkillDirectory directory, - ) async { - return null; - } - - @override - Future resolveUserHomeDirectory() async { - return homeDirectory; - } -} - -class _GoldenGoTaskServiceClient implements GoTaskServiceClient { - const _GoldenGoTaskServiceClient(); - - @override - Future cancelTask({ - required GoTaskServiceRoute route, - required AssistantExecutionTarget target, - required String sessionId, - required String threadId, - }) async {} - - @override - Future closeTask({ - required GoTaskServiceRoute route, - required AssistantExecutionTarget target, - required String sessionId, - required String threadId, - }) async {} - - @override - Future dispose() async {} - - @override - Future executeTask( - GoTaskServiceRequest request, { - required void Function(GoTaskServiceUpdate update) onUpdate, - }) async { - return const GoTaskServiceResult( - success: true, - message: '', - turnId: '', - raw: {}, - errorMessage: '', - resolvedModel: '', - route: GoTaskServiceRoute.externalAcpSingle, - ); - } - - @override - Future loadExternalAcpCapabilities({ - required AssistantExecutionTarget target, - bool forceRefresh = false, - }) async { - return const ExternalCodeAgentAcpCapabilities( - singleAgent: true, - multiAgent: true, - providerCatalog: [SingleAgentProvider.codex], - gatewayProviders: >[], - raw: {}, - ); - } - - @override - Future resolveExternalAcpRouting({ - required String taskPrompt, - required String workingDirectory, - required ExternalCodeAgentAcpRoutingConfig routing, - }) async { - return const ExternalCodeAgentAcpRoutingResolution( - raw: { - 'resolvedExecutionTarget': 'single-agent', - 'resolvedEndpointTarget': 'singleAgent', - 'resolvedProviderId': 'codex', - 'resolvedModel': '', - 'resolvedSkills': [], - 'unavailable': false, - }, - ); - } - - @override - Future syncExternalProviders( - List providers, - ) async {} -} diff --git a/test/features/assistant/goldens/assistant_page_composer_working_directory.png b/test/features/assistant/goldens/assistant_page_composer_working_directory.png deleted file mode 100644 index a76ab0de..00000000 Binary files a/test/features/assistant/goldens/assistant_page_composer_working_directory.png and /dev/null differ diff --git a/test/features/settings/goldens/settings_page_account_status_canonical.png b/test/features/settings/goldens/settings_page_account_status_canonical.png deleted file mode 100644 index c2c03fe3..00000000 Binary files a/test/features/settings/goldens/settings_page_account_status_canonical.png and /dev/null differ diff --git a/test/features/settings/settings_page_core_test.dart b/test/features/settings/settings_page_core_test.dart index c8adc138..abf85bea 100644 --- a/test/features/settings/settings_page_core_test.dart +++ b/test/features/settings/settings_page_core_test.dart @@ -165,24 +165,6 @@ void main() { }, ); - testWidgets('renders the signed-out login card consistently', ( - tester, - ) async { - await tester.binding.setSurfaceSize(const Size(1600, 1200)); - addTearDown(() async => tester.binding.setSurfaceSize(null)); - final fixtures = _buildSettingsPageFixtures( - seed: _SettingsAccountSeed.signedOut, - ); - final controller = fixtures.controller; - - await tester.pumpWidget(_buildSettingsPageApp(controller)); - await tester.pump(const Duration(milliseconds: 300)); - - await expectLater( - find.byKey(const ValueKey('settings-page-boundary')), - matchesGoldenFile('goldens/settings_page_account_status_canonical.png'), - ); - }); }); } @@ -190,13 +172,10 @@ Widget _buildSettingsPageApp(_FakeSettingsPageController controller) { return MaterialApp( theme: AppTheme.light(platform: TargetPlatform.macOS), home: Scaffold( - body: RepaintBoundary( - key: const ValueKey('settings-page-boundary'), - child: SizedBox( - width: 1600, - height: 1200, - child: SettingsPage(controller: controller), - ), + body: SizedBox( + width: 1600, + height: 1200, + child: SettingsPage(controller: controller), ), ), ); diff --git a/test/runtime/bridge_real_e2e_test.dart b/test/runtime/bridge_real_e2e_test.dart deleted file mode 100644 index 1719c440..00000000 --- a/test/runtime/bridge_real_e2e_test.dart +++ /dev/null @@ -1,365 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:xworkmate/runtime/desktop_thread_artifact_sync.dart'; -import 'package:xworkmate/runtime/external_code_agent_acp_desktop_transport.dart'; -import 'package:xworkmate/runtime/gateway_acp_client.dart'; -import 'package:xworkmate/runtime/go_task_service_client.dart'; -import 'package:xworkmate/runtime/runtime_models.dart'; - -const _providerEndpoints = { - 'codex': 'https://acp-server.svc.plus/codex/acp/rpc', - 'opencode': 'https://acp-server.svc.plus/opencode/acp/rpc', - 'gemini': 'https://acp-server.svc.plus/gemini/acp/rpc', -}; - -const _tinyPngBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0x8AAAAASUVORK5CYII='; - -void main() { - final runRealE2E = - Platform.environment['RUN_REAL_BRIDGE_E2E'] == '1' || - Platform.environment['RUN_REAL_BRIDGE_E2E'] == 'true'; - final bridgeAuthToken = - Platform.environment['BRIDGE_AUTH_TOKEN']?.trim() ?? ''; - final bridgeAcpEndpoint = - Platform.environment['BRIDGE_ACP_ENDPOINT']?.trim() ?? ''; - final openclawGatewayToken = - Platform.environment['OPENCLAW_GATEWAY_TOKEN']?.trim() ?? ''; - - group('real bridge provider matrix', () { - late ExternalCodeAgentAcpDesktopTransport transport; - - setUpAll(() async { - if (!runRealE2E || bridgeAuthToken.isEmpty || bridgeAcpEndpoint.isEmpty) { - return; - } - final client = GatewayAcpClient( - endpointResolver: () => Uri.parse(bridgeAcpEndpoint), - authorizationResolver: (_) async => 'Bearer $bridgeAuthToken', - ); - transport = ExternalCodeAgentAcpDesktopTransport( - client: client, - endpointResolver: (_) => Uri.parse(bridgeAcpEndpoint), - ); - await transport.syncExternalProviders( - _providerEndpoints.entries - .map( - (entry) => ExternalCodeAgentAcpSyncedProvider( - providerId: entry.key, - label: entry.key, - endpoint: entry.value, - authorizationHeader: 'Bearer $bridgeAuthToken', - enabled: true, - ), - ) - .toList(growable: false), - ); - }); - - tearDownAll(() async { - if (runRealE2E && - bridgeAuthToken.isNotEmpty && - bridgeAcpEndpoint.isNotEmpty) { - await transport.dispose(); - } - }); - - test('loads external ACP capabilities and provider catalog', () async { - if (!runRealE2E || bridgeAuthToken.isEmpty || bridgeAcpEndpoint.isEmpty) { - return; - } - final capabilities = await transport.loadExternalAcpCapabilities( - target: AssistantExecutionTarget.singleAgent, - ); - expect(capabilities.singleAgent, isTrue); - expect( - capabilities.providerCatalog.map((item) => item.providerId), - containsAll(['codex', 'opencode', 'gemini']), - ); - }); - - for (final providerId in _providerEndpoints.keys) { - test('$providerId supports a two-turn conversation', () async { - if (!runRealE2E || - bridgeAuthToken.isEmpty || - bridgeAcpEndpoint.isEmpty) { - return; - } - final workdir = await Directory.systemTemp.createTemp( - 'xworkmate-$providerId-conversation-', - ); - addTearDown(() async { - if (await workdir.exists()) { - await workdir.delete(recursive: true); - } - }); - - final startResult = await transport.executeTask( - _buildRequest( - providerId: providerId, - sessionId: 'conversation-$providerId', - threadId: 'conversation-$providerId', - workingDirectory: workdir.path, - prompt: 'Reply with exactly pong.', - ), - onUpdate: (_) {}, - ); - expect(startResult.success, isTrue); - expect(startResult.resolvedProviderId, providerId); - - final messageResult = await transport.executeTask( - _buildRequest( - providerId: providerId, - sessionId: 'conversation-$providerId', - threadId: 'conversation-$providerId', - workingDirectory: workdir.path, - prompt: 'Reply with exactly round2.', - resumeSession: true, - ), - onUpdate: (_) {}, - ); - expect(messageResult.success, isTrue); - expect(messageResult.resolvedProviderId, providerId); - expect( - messageResult.message.toLowerCase(), - contains('round2'), - reason: 'follow-up should stay on the same provider/thread', - ); - }); - } - - for (final providerId in ['codex', 'opencode']) { - for (final scenario in _artifactScenarios) { - test( - '$providerId can return ${scenario.skill} artifacts to local workspace', - () async { - if (!runRealE2E || bridgeAuthToken.isEmpty) { - return; - } - final workdir = await Directory.systemTemp.createTemp( - 'xworkmate-$providerId-${scenario.skill}-', - ); - addTearDown(() async { - if (await workdir.exists()) { - await workdir.delete(recursive: true); - } - }); - await scenario.prepare?.call(workdir); - - final result = await transport.executeTask( - _buildRequest( - providerId: providerId, - sessionId: '$providerId-${scenario.skill}', - threadId: '$providerId-${scenario.skill}', - workingDirectory: workdir.path, - prompt: scenario.prompt, - selectedSkills: [scenario.skill], - ), - onUpdate: (_) {}, - ); - - expect(result.success, isTrue, reason: result.errorMessage); - expect(result.resolvedProviderId, providerId); - expect(result.remoteWorkingDirectory.trim(), isNotEmpty); - expect(result.remoteWorkspaceRefKind, WorkspaceRefKind.remotePath); - expect(result.resultSummary.trim(), isNotEmpty); - expect(result.artifacts, isNotEmpty); - - final syncResult = await syncInlineArtifactsToLocalWorkspace( - root: workdir, - artifacts: result.artifacts, - ); - expect(syncResult.wroteArtifact, isTrue); - expect( - syncResult.writtenFiles.any( - (path) => path.endsWith(scenario.expectedSuffix), - ), - isTrue, - ); - }, - timeout: const Timeout(Duration(minutes: 4)), - ); - } - } - - for (final scenario in _artifactScenarios) { - test( - 'gemini reports either success or a provider limitation for ${scenario.skill}', - () async { - if (!runRealE2E || bridgeAuthToken.isEmpty) { - return; - } - final workdir = await Directory.systemTemp.createTemp( - 'xworkmate-gemini-${scenario.skill}-', - ); - addTearDown(() async { - if (await workdir.exists()) { - await workdir.delete(recursive: true); - } - }); - await scenario.prepare?.call(workdir); - - final result = await transport.executeTask( - _buildRequest( - providerId: 'gemini', - sessionId: 'gemini-${scenario.skill}', - threadId: 'gemini-${scenario.skill}', - workingDirectory: workdir.path, - prompt: scenario.prompt, - selectedSkills: [scenario.skill], - ), - onUpdate: (_) {}, - ); - - expect(result.resolvedProviderId, 'gemini'); - if (result.success) { - final syncResult = await syncInlineArtifactsToLocalWorkspace( - root: workdir, - artifacts: result.artifacts, - ); - expect(syncResult.wroteArtifact, isTrue); - } else { - expect( - result.errorMessage.trim().isNotEmpty || - result.message.trim().isNotEmpty, - isTrue, - reason: - 'provider limitation should still surface a clear summary', - ); - } - }, - timeout: const Timeout(Duration(minutes: 4)), - ); - } - }); - - group('bridge-owned deployment examples', () { - test('default gateway profile starts unconfigured', () { - final profile = GatewayConnectionProfile.defaults(); - expect(profile.host, isEmpty); - expect(profile.port, 443); - expect(profile.tls, isTrue); - expect(profile.mode, RuntimeConnectionMode.unconfigured); - }); - - test('wss endpoint is reachable', () async { - if (!runRealE2E) { - return; - } - final client = HttpClient(); - addTearDown(client.close); - final request = await client.getUrl( - Uri.parse('https://openclaw.svc.plus'), - ); - final response = await request.close(); - expect(response.statusCode, anyOf(200, 400, 401, 403, 404, 426)); - }); - - test( - 'gateway token is wired for future remote runtime coverage', - () { - if (!runRealE2E) { - return; - } - expect( - openclawGatewayToken.isNotEmpty, - isTrue, - reason: - 'Set OPENCLAW_GATEWAY_TOKEN to run remote gateway-chat coverage against openclaw.svc.plus.', - ); - }, - skip: !runRealE2E || openclawGatewayToken.isNotEmpty, - ); - }); -} - -class _ArtifactScenario { - const _ArtifactScenario({ - required this.skill, - required this.prompt, - required this.expectedSuffix, - this.prepare, - }); - - final String skill; - final String prompt; - final String expectedSuffix; - final Future Function(Directory root)? prepare; -} - -final _artifactScenarios = <_ArtifactScenario>[ - const _ArtifactScenario( - skill: 'docx', - prompt: - 'Use the docx skill to create report.docx in the working directory. Include a title and a 2-column table with two rows.', - expectedSuffix: '/report.docx', - ), - const _ArtifactScenario( - skill: 'pptx', - prompt: - 'Use the pptx skill to create deck.pptx in the working directory with two slides titled Intro and Summary.', - expectedSuffix: '/deck.pptx', - ), - const _ArtifactScenario( - skill: 'xlsx', - prompt: - 'Use the xlsx skill to create sales.xlsx in the working directory with a totals formula column.', - expectedSuffix: '/sales.xlsx', - ), - const _ArtifactScenario( - skill: 'pdf', - prompt: - 'Use the pdf skill to create summary.pdf in the working directory with a one-page summary of bridge validation.', - expectedSuffix: '/summary.pdf', - ), - _ArtifactScenario( - skill: 'image-resizer', - prompt: - 'Use the image-resizer skill to resize input.png to 1200x800 and save the output as resized.png in the working directory.', - expectedSuffix: '/resized.png', - prepare: (root) async { - final bytes = base64Decode(_tinyPngBase64); - await File('${root.path}/input.png').writeAsBytes(bytes, flush: true); - }, - ), -]; - -GoTaskServiceRequest _buildRequest({ - required String providerId, - required String sessionId, - required String threadId, - required String workingDirectory, - required String prompt, - List selectedSkills = const [], - bool resumeSession = false, -}) { - return GoTaskServiceRequest( - sessionId: sessionId, - threadId: threadId, - target: AssistantExecutionTarget.singleAgent, - prompt: prompt, - workingDirectory: workingDirectory, - model: '', - thinking: '', - selectedSkills: selectedSkills, - inlineAttachments: const [], - localAttachments: const [], - agentId: '', - metadata: const {}, - routing: ExternalCodeAgentAcpRoutingConfig( - mode: ExternalCodeAgentAcpRoutingMode.explicit, - preferredGatewayTarget: 'gateway', - explicitExecutionTarget: 'singleAgent', - explicitProviderId: providerId, - explicitModel: '', - explicitSkills: selectedSkills, - allowSkillInstall: false, - availableSkills: const [], - ), - provider: SingleAgentProviderCopy.fromJsonValue(providerId), - remoteWorkingDirectoryHint: '', - resumeSession: resumeSession, - ); -} diff --git a/test/runtime/settings_snapshot_provider_sync_definitions_test.dart b/test/runtime/settings_snapshot_provider_sync_definitions_test.dart index 3e981efa..e56928e7 100644 --- a/test/runtime/settings_snapshot_provider_sync_definitions_test.dart +++ b/test/runtime/settings_snapshot_provider_sync_definitions_test.dart @@ -40,6 +40,16 @@ void main() { expect(decoded.toJson().containsKey('codexCliPath'), isFalse); }); + test('single-agent provider selection preserves bridge catalog ids', () { + final decoded = SettingsSnapshot.defaults(); + final provider = SingleAgentProvider.fromJsonValue( + 'xworkmate-bridge-foo', + label: 'Bridge Foo', + ); + + expect(decoded.sanitizeSingleAgentProviderSelection(provider), provider); + }); + test('removed ui restore and local provider fields are not serialized', () { final json = SettingsSnapshot.defaults().toJson(); @@ -55,27 +65,34 @@ void main() { }); group('AcpBridgeServerModeConfig advanced overrides', () { - test('legacy ACP bridge server profiles are ignored and not reserialized', () { - final config = AcpBridgeServerModeConfig.fromJson({ - 'advancedOverrides': { - 'acpBridgeServerProfiles': >[ - { - 'providerKey': 'opencode', - 'label': 'OpenCode', - 'badge': 'O', - 'endpoint': 'https://opencode.example.com', - 'authRef': 'secret://opencode', - 'enabled': true, - }, - ], - }, - }); + test( + 'legacy ACP bridge server profiles are ignored and not reserialized', + () { + final config = AcpBridgeServerModeConfig.fromJson({ + 'advancedOverrides': { + 'acpBridgeServerProfiles': >[ + { + 'providerKey': 'opencode', + 'label': 'OpenCode', + 'badge': 'O', + 'endpoint': 'https://opencode.example.com', + 'authRef': 'secret://opencode', + 'enabled': true, + }, + ], + }, + }); - final json = config.toJson(); - final advancedOverrides = (json['advancedOverrides'] as Map?)?.cast(); + final json = config.toJson(); + final advancedOverrides = (json['advancedOverrides'] as Map?) + ?.cast(); - expect(advancedOverrides, isNotNull); - expect(advancedOverrides!.containsKey('acpBridgeServerProfiles'), isFalse); - }); + expect(advancedOverrides, isNotNull); + expect( + advancedOverrides!.containsKey('acpBridgeServerProfiles'), + isFalse, + ); + }, + ); }); }