From 53d411fb9e0117cedb46de78770d7c33e5ddec38 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 12 Apr 2026 15:42:15 +0800 Subject: [PATCH] Refactor bridge provider readiness and trim stale tests --- docs/README_TESTING.md | 8 +- ...ettings-integration-configuration-model.md | 6 +- .../task-control-plane-unification.md | 4 +- docs/testing/test-case-coverage-matrix.md | 17 +- lib/app/app_controller_desktop_core.dart | 53 ++- ...ler_desktop_runtime_coordination_impl.dart | 5 +- ...pp_controller_desktop_runtime_helpers.dart | 70 ++-- ...p_controller_desktop_settings_runtime.dart | 1 + ...ler_desktop_single_agent_go_task_flow.dart | 20 +- ..._desktop_single_agent_status_messages.dart | 6 +- ..._controller_desktop_skill_permissions.dart | 3 + ...app_controller_desktop_thread_binding.dart | 23 +- ...pp_controller_desktop_thread_sessions.dart | 67 ++-- ...app_controller_desktop_thread_storage.dart | 6 +- ...ontroller_desktop_workspace_execution.dart | 3 + .../assistant/assistant_page_components.dart | 47 ++- .../assistant_page_composer_bar.dart | 7 +- lib/features/modules/modules_page.dart | 2 +- .../runtime_models_runtime_payloads.dart | 14 + .../runtime_models_settings_snapshot.dart | 8 +- pubspec.lock | 8 - pubspec.yaml | 1 - ...ntroller_desktop_runtime_cleanup_test.dart | 136 ++++++- ...ontroller_desktop_thread_binding_test.dart | 185 ++++++--- ...sktop_working_directory_dispatch_test.dart | 2 +- ...t_execution_target_picker_widget_test.dart | 62 ++- .../assistant_page_composer_golden_test.dart | 232 ----------- ...istant_page_composer_working_directory.png | Bin 7037 -> 0 bytes ...settings_page_account_status_canonical.png | Bin 27677 -> 0 bytes .../settings/settings_page_core_test.dart | 29 +- test/runtime/bridge_real_e2e_test.dart | 365 ------------------ ...apshot_provider_sync_definitions_test.dart | 57 ++- 32 files changed, 542 insertions(+), 905 deletions(-) delete mode 100644 test/features/assistant/assistant_page_composer_golden_test.dart delete mode 100644 test/features/assistant/goldens/assistant_page_composer_working_directory.png delete mode 100644 test/features/settings/goldens/settings_page_account_status_canonical.png delete mode 100644 test/runtime/bridge_real_e2e_test.dart 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 a76ab0deabd3e6aa6f98531b9b532e3e5eb92427..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7037 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 U34#NEuz^5U=C)>}3%)=88&zy}uK)l5 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 c2c03fe38c1e113471b49ca71950472333ca645c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27677 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 a5G13%j>TOuHnT!|vbAc0cHq)5U5 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, + ); + }, + ); }); }