From 74daa6461d82a3614d87fdf833e1c985bbd6ea4c Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 10 Apr 2026 11:55:36 +0800 Subject: [PATCH] Align bridge core path and secure account sync --- .../task-control-plane-unification.md | 18 +-- .../xworkmate-bridge-migration.md | 8 +- .../xworkmate-layered-architecture.md | 29 ++--- ...ntroller_desktop_external_acp_routing.dart | 32 +++++- ...controller_desktop_runtime_exceptions.dart | 14 +++ ...pp_controller_desktop_runtime_helpers.dart | 19 +--- ...app_controller_desktop_thread_binding.dart | 21 ++++ .../runtime_models_settings_snapshot.dart | 29 ++++- test/runtime/account_bridge_smoke_suite.dart | 43 ++++---- .../acp_bridge_provider_hub_suite.dart | 102 +++++++++++++++++ ...ntroller_assistant_workspace_ref_test.dart | 18 +-- ...ent_workspace_binding_regression_test.dart | 103 ++++++++++++------ 12 files changed, 320 insertions(+), 116 deletions(-) create mode 100644 lib/app/app_controller_desktop_runtime_exceptions.dart create mode 100644 test/runtime/acp_bridge_provider_hub_suite.dart diff --git a/docs/architecture/task-control-plane-unification.md b/docs/architecture/task-control-plane-unification.md index 35d09bd1..6949d7a8 100644 --- a/docs/architecture/task-control-plane-unification.md +++ b/docs/architecture/task-control-plane-unification.md @@ -18,7 +18,8 @@ Last Updated: 2026-04-08 - UI 不变 - `GoTaskService.executeTask` 是唯一公开入口 - ACP 是统一控制面 -- `single-agent / multi-agent / gateway` 是 ACP 解析后的执行器分支 +- `bridge` 是 app 客户端的发现 / 配置 / 连接 / 对话枢纽 +- 账户同步只同步 bridge 相关配置属性与安全引用,不做自动连接 ## 目标态 @@ -32,13 +33,11 @@ flowchart TD F --> G["Memory.Inject"] G --> H["buildResolvedExecutionParams"] H --> I{"resolvedExecutionTarget"} - I -->|"single-agent"| J["single-agent executor"] - I -->|"multi-agent"| K["multi-agent executor"] - I -->|"gateway"| L["gateway executor"] - J --> M["Adapter Layer"] + I -->|"single-agent"| J["single-agent ACP request"] + I -->|"multi-agent"| K["multi-agent ACP request"] + J --> M["bridge hub"] K --> M - L --> M - M --> N["External Runtime / Relay / Gateway"] + M --> N["Gateway / Provider adapters"] N --> O["stream events / result"] O --> P["Memory.Record"] P --> Q["Update Thread State"] @@ -51,7 +50,8 @@ flowchart TD - Desktop App 直接桥接 Go 代码 - Desktop 正常执行链路不以“先启动一个本地 HTTP server,再由 Desktop 自己回连”作为目标架构 -- Desktop 的 `sendMessage -> GoTaskService.executeTask -> ACP` 应理解为进程内或直接桥接语义,不是 Web server 回环语义 +- Desktop 的 `sendMessage -> GoTaskService.executeTask -> ACP` 应理解为进程内或直接桥接语义 +- 对 app 来说,bridge 是 discovery / config / connect / dialogue 的统一枢纽 ### Web / Mobile @@ -99,5 +99,5 @@ flowchart TD 1. 文档口径收敛 2. Dart 请求模型统一 3. route 决策内收到 `GoTaskService` / ACP -4. `gateway` 成为 ACP executor +4. app 侧 bridge 枢纽与 provider / gateway 适配关系收敛 5. `multi-agent` 成为统一请求语义 diff --git a/docs/architecture/xworkmate-bridge-migration.md b/docs/architecture/xworkmate-bridge-migration.md index d8a6b417..8deca5c7 100644 --- a/docs/architecture/xworkmate-bridge-migration.md +++ b/docs/architecture/xworkmate-bridge-migration.md @@ -4,7 +4,7 @@ The ACP Bridge Server implementation was migrated out of `xworkmate-app` into the standalone sibling repository `xworkmate-bridge`. -This migration separates the embedded Go bridge/server from the Flutter application repository while preserving the existing helper binary contract used by the app. +This migration separates the embedded Go bridge/server from the Flutter application repository. The app now depends on the sibling `xworkmate-bridge` repo for the helper/runtime contract instead of carrying an in-repo Go bridge copy. ## New Repository @@ -33,9 +33,9 @@ The following app-side concerns remain in `xworkmate-app`: ## Build Contract -`xworkmate-app` still expects a helper named `xworkmate-go-core`. +`xworkmate-app` expects the helper artifact named `xworkmate-go-core`. -To preserve compatibility, `xworkmate-bridge` continues to build the helper using that binary name. +This is the current cross-repo runtime contract, not a legacy compatibility shim. The helper is built from `xworkmate-bridge` and consumed by `xworkmate-app`. ## App Repository Changes @@ -57,3 +57,5 @@ Validated during migration: ## Operational Note For local development and packaging, `xworkmate-bridge` must exist as a sibling repository next to `xworkmate-app`, unless `XWORKMATE_BRIDGE_DIR` is set explicitly. + +At runtime, the app treats bridge-related discovery, provider sync, connection metadata, and ACP conversation forwarding as bridge-owned concerns. Account sync only updates bridge-linked configuration attributes and secure secret references; it does not auto-connect the bridge. diff --git a/docs/architecture/xworkmate-layered-architecture.md b/docs/architecture/xworkmate-layered-architecture.md index de08fa0d..dffd2059 100644 --- a/docs/architecture/xworkmate-layered-architecture.md +++ b/docs/architecture/xworkmate-layered-architecture.md @@ -11,8 +11,9 @@ Last Updated: 2026-04-08 - `TaskThread` 是线程控制面 - `GoTaskService.executeTask` 是唯一公开执行入口 - ACP 是统一控制面 -- `single-agent / multi-agent / gateway` 是 ACP 解析后的执行器分支 -- 兼容旁路不再作为架构目标 +- `bridge` 是 app 客户端侧的发现 / 配置 / 连接 / 对话枢纽 +- 账户同步只同步 bridge 相关配置属性与安全引用,不负责自动连接 +- 历史旁路与旧的直连叙述不再作为目标架构 ## 总览图 @@ -51,11 +52,11 @@ flowchart TB E5["buildResolvedExecutionParams"] end - subgraph L6["Executors / Adapters"] - F1["single-agent executor"] - F2["multi-agent executor"] - F3["gateway executor"] - F4["GatewayRuntime / Web relay / GatewayAcpClient"] + subgraph L6["Bridge / Executors / Adapters"] + F1["single-agent ACP request"] + F2["multi-agent ACP request"] + F3["bridge hub
discovery / config / connect / dialogue"] + F4["gateway / provider adapters"] end A1 --> B1 @@ -77,7 +78,8 @@ flowchart TB E4 --> E5 E5 --> F1 E5 --> F2 - E5 --> F3 + F1 --> F3 + F2 --> F3 F3 --> F4 ``` @@ -87,24 +89,25 @@ flowchart TB 2. `TaskThread` 承载线程级事实,不由页面局部状态拼装。 3. `GoTaskService.executeTask` 是唯一公开任务入口。 4. ACP 是统一控制面,负责 routing / skills / memory / resolved execution。 -5. `gateway` 是执行器分支,不是 UI 旁路目标。 +5. `bridge` 是 app 侧统一枢纽;gateway/provider 适配能力挂在 bridge 后面,不再把历史直连路径写成长期主链。 ## 文档目录 ### 目标规范 -- [任务执行链路统一收敛](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/task-control-plane-unification.md) +- [任务执行链路统一收敛](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-control-plane-unification.md) +- [ACP Forwarding Topology](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/acp-forwarding-topology.md) ### 当前实现观察 - 当前实现观察不再保留独立主设计文档 -- 如需判断规范,以 [任务执行链路统一收敛](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/task-control-plane-unification.md) 为准 +- 如需判断规范,以 [任务执行链路统一收敛](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-control-plane-unification.md) 为准 ### 边界与适配器说明 - 适配器边界统一收敛到本文件与主文档,不再保留旧的并列设计稿 -## Compatibility route (removed from target) +## Removed From Target - 旧的 `openClawTask` 公开语义不再是目标架构的一部分 -- `GatewayRuntime`、`Web relay`、`GatewayAcpClient` 只作为 adapter/executor 能力存在 +- 不再把“客户端直接围绕旧 gateway 默认值运转”写成长期主设计 diff --git a/lib/app/app_controller_desktop_external_acp_routing.dart b/lib/app/app_controller_desktop_external_acp_routing.dart index e578de66..ff136d55 100644 --- a/lib/app/app_controller_desktop_external_acp_routing.dart +++ b/lib/app/app_controller_desktop_external_acp_routing.dart @@ -41,23 +41,43 @@ extension AppControllerDesktopExternalAcpRouting on AppController { buildExternalAcpSyncedProvidersInternal() async { final providers = []; for (final profile in settings.externalAcpEndpoints) { - final providerId = profile.providerKey.trim(); - final endpoint = profile.endpoint.trim(); + final builtinProvider = profile.builtinProvider; + final effectiveProfile = builtinProvider == null + ? profile + : settings.externalAcpEndpointForProvider(builtinProvider); + final providerId = effectiveProfile.providerKey.trim(); + final endpoint = effectiveProfile.endpoint.trim(); if (providerId.isEmpty || endpoint.isEmpty) { continue; } - final authorizationHeader = profile.authRef.trim().isEmpty + var authorizationHeader = effectiveProfile.authRef.trim().isEmpty ? '' : await settingsControllerInternal.resolveSecretValueInternal( - refName: profile.authRef.trim(), + refName: effectiveProfile.authRef.trim(), ); + if (authorizationHeader.isEmpty && + builtinProvider != null && + settings.acpBridgeServerModeConfig.usesSelfHostedBase) { + final selfHosted = settings.acpBridgeServerModeConfig.selfHosted; + final username = selfHosted.username.trim(); + final passwordRef = selfHosted.passwordRef.trim(); + final password = passwordRef.isEmpty + ? '' + : await settingsControllerInternal.loadSecretValueByRef( + passwordRef, + ); + if (username.isNotEmpty && password.trim().isNotEmpty) { + authorizationHeader = + 'Basic ${base64Encode(utf8.encode('$username:${password.trim()}'))}'; + } + } providers.add( ExternalCodeAgentAcpSyncedProvider( providerId: providerId, - label: profile.label, + label: effectiveProfile.label, endpoint: endpoint, authorizationHeader: authorizationHeader, - enabled: profile.enabled, + enabled: effectiveProfile.enabled, ), ); } diff --git a/lib/app/app_controller_desktop_runtime_exceptions.dart b/lib/app/app_controller_desktop_runtime_exceptions.dart new file mode 100644 index 00000000..dde7df21 --- /dev/null +++ b/lib/app/app_controller_desktop_runtime_exceptions.dart @@ -0,0 +1,14 @@ +class AiGatewayChatExceptionInternal implements Exception { + const AiGatewayChatExceptionInternal(this.message); + + final String message; + + @override + String toString() => message; +} + +class AiGatewayAbortExceptionInternal implements Exception { + const AiGatewayAbortExceptionInternal(this.partialText); + + final String partialText; +} diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 5a6dac1b..154ccbc7 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -44,6 +44,7 @@ import 'app_controller_desktop_settings_runtime.dart'; import 'app_controller_desktop_thread_storage.dart'; import 'app_controller_desktop_skill_permissions.dart'; import 'app_controller_desktop_runtime_coordination_impl.dart'; +import 'app_controller_desktop_runtime_exceptions.dart'; // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member extension AppControllerDesktopRuntimeHelpers on AppController { @@ -758,7 +759,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController { RuntimeConnectionMode mode, ) { return switch (mode) { - RuntimeConnectionMode.unconfigured => AssistantExecutionTarget.singleAgent, + RuntimeConnectionMode.unconfigured => + AssistantExecutionTarget.singleAgent, RuntimeConnectionMode.local => AssistantExecutionTarget.local, RuntimeConnectionMode.remote => AssistantExecutionTarget.remote, }; @@ -788,18 +790,3 @@ extension AppControllerDesktopRuntimeHelpers on AppController { }; } } - -class AiGatewayChatExceptionInternal implements Exception { - const AiGatewayChatExceptionInternal(this.message); - - final String message; - - @override - String toString() => message; -} - -class AiGatewayAbortExceptionInternal implements Exception { - const AiGatewayAbortExceptionInternal(this.partialText); - - final String partialText; -} diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index bd490807..b3375d0c 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -46,6 +46,23 @@ import 'app_controller_desktop_skill_permissions.dart'; import 'app_controller_desktop_runtime_helpers.dart'; extension AppControllerDesktopThreadBinding on AppController { + String managedLocalThreadWorkspaceSuffixInternal(String sessionKey) => + '/.xworkmate/threads/${threadWorkspaceDirectoryNameInternal(sessionKey)}'; + + bool isManagedLocalThreadWorkspacePathInternal( + String path, + String sessionKey, + ) { + final normalizedPath = trimTrailingPathSeparatorInternal(path.trim()); + if (normalizedPath.isEmpty) { + return false; + } + final normalizedSuffix = managedLocalThreadWorkspaceSuffixInternal( + sessionKey, + ); + return normalizedPath.endsWith(normalizedSuffix); + } + String localThreadWorkspacePathInternal(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, @@ -161,6 +178,10 @@ extension AppControllerDesktopThreadBinding on AppController { if (executionTarget == AssistantExecutionTarget.singleAgent) { if (existingBinding != null && existingBinding.workspaceKind == WorkspaceKind.localFs && + !isManagedLocalThreadWorkspacePathInternal( + existingBinding.workspacePath, + sessionKey, + ) && ensureLocalWorkspaceDirectoryInternal( existingBinding.workspacePath, )) { diff --git a/lib/runtime/runtime_models_settings_snapshot.dart b/lib/runtime/runtime_models_settings_snapshot.dart index e9eb974b..6d91668a 100644 --- a/lib/runtime/runtime_models_settings_snapshot.dart +++ b/lib/runtime/runtime_models_settings_snapshot.dart @@ -516,8 +516,21 @@ class SettingsSnapshot { ExternalAcpEndpointProfile externalAcpEndpointForProvider( SingleAgentProvider provider, ) { - return externalAcpEndpointForProviderId(provider.providerId) ?? + final profile = + externalAcpEndpointForProviderId(provider.providerId) ?? ExternalAcpEndpointProfile.defaultsForProvider(provider); + final bridgeBaseUrl = acpBridgeBuiltinEndpointBaseUrl; + if (provider.isAuto || bridgeBaseUrl.isEmpty) { + return profile; + } + return profile.copyWith(endpoint: bridgeBaseUrl); + } + + String get acpBridgeBuiltinEndpointBaseUrl { + if (!acpBridgeServerModeConfig.usesSelfHostedBase) { + return ''; + } + return acpBridgeServerModeConfig.selfHosted.serverUrl.trim(); } ExternalAcpEndpointProfile? externalAcpEndpointForProviderId( @@ -591,9 +604,17 @@ class SettingsSnapshot { List get savedSingleAgentProviders => normalizeSingleAgentProviderList( - externalAcpEndpoints - .where((item) => item.enabled && item.endpoint.trim().isNotEmpty) - .map((item) => item.toProvider()), + externalAcpEndpoints.map((item) { + final provider = item.toProvider(); + if (provider.isAuto) { + return null; + } + final effective = externalAcpEndpointForProvider(provider); + if (!effective.enabled || effective.endpoint.trim().isEmpty) { + return null; + } + return effective.toProvider(); + }).whereType(), ); bool isGatewayTargetSaved(AssistantExecutionTarget target) { diff --git a/test/runtime/account_bridge_smoke_suite.dart b/test/runtime/account_bridge_smoke_suite.dart index ca97c947..024646b6 100644 --- a/test/runtime/account_bridge_smoke_suite.dart +++ b/test/runtime/account_bridge_smoke_suite.dart @@ -9,7 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/runtime/account_runtime_client.dart'; -import 'package:xworkmate/runtime/gateway_acp_client.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_controllers.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; @@ -130,10 +129,7 @@ void main() { workingDirectory: tempDir.path, prompt: '请检查 ACP 路由和 gateway 路由', ); - expect( - routeResolution['result'] != null, - isTrue, - ); + expect(routeResolution['result'] != null, isTrue); final workspacePath = controller.assistantWorkspacePathForSession( controller.currentSessionKey, ); @@ -188,14 +184,12 @@ class _SmokeEnv { env['BRIDGE_URL'] ?? 'https://xworkmate-bridge.svc.plus'; final codexProviderEndpoint = - env['CODEX_PROVIDER_ENDPOINT'] ?? - 'https://acp-server.svc.plus/codex'; + env['CODEX_PROVIDER_ENDPOINT'] ?? 'https://acp-server.svc.plus/codex'; final opencodeProviderEndpoint = env['OPENCODE_PROVIDER_ENDPOINT'] ?? 'https://acp-server.svc.plus/opencode'; final geminiProviderEndpoint = - env['GEMINI_PROVIDER_ENDPOINT'] ?? - 'https://acp-server.svc.plus/gemini'; + env['GEMINI_PROVIDER_ENDPOINT'] ?? 'https://acp-server.svc.plus/gemini'; if (accountLoginName.trim().isEmpty || accountLoginPassword.trim().isEmpty || bridgeAuthToken.trim().isEmpty) { @@ -238,16 +232,11 @@ class _BridgeGoTaskServiceClient implements GoTaskServiceClient { final String bridgeBaseUrl; final String bridgeAuthToken; - List _providers = - const []; @override Future syncExternalProviders( List providers, ) async { - _providers = List.unmodifiable( - providers, - ); await _request( method: 'xworkmate.providers.sync', params: { @@ -257,7 +246,8 @@ class _BridgeGoTaskServiceClient implements GoTaskServiceClient { 'providerId': item.providerId, 'label': item.label, 'endpoint': item.endpoint, - 'authorizationHeader': item.authorizationHeader.startsWith('Bearer ') + 'authorizationHeader': + item.authorizationHeader.startsWith('Bearer ') ? item.authorizationHeader : 'Bearer ${item.authorizationHeader}', 'enabled': item.enabled, @@ -277,14 +267,17 @@ class _BridgeGoTaskServiceClient implements GoTaskServiceClient { method: 'acp.capabilities', params: const {}, ); - final result = (response['result'] as Map?)?.cast() ?? + final result = + (response['result'] as Map?)?.cast() ?? const {}; final providers = {}; for (final raw in [ ..._asList(result['providers']), - ..._asList(result['capabilities'] is Map - ? (result['capabilities'] as Map)['providers'] - : null), + ..._asList( + result['capabilities'] is Map + ? (result['capabilities'] as Map)['providers'] + : null, + ), ]) { if (raw == null) { continue; @@ -313,7 +306,8 @@ class _BridgeGoTaskServiceClient implements GoTaskServiceClient { method: request.resumeSession ? 'session.message' : 'session.start', params: request.toExternalAcpParams(), ); - final result = (response['result'] as Map?)?.cast() ?? + final result = + (response['result'] as Map?)?.cast() ?? const {}; final message = result['output']?.toString().trim().isNotEmpty == true ? result['output'].toString().trim() @@ -390,11 +384,12 @@ class _BridgeGoTaskServiceClient implements GoTaskServiceClient { }) async { final client = HttpClient(); try { - final request = await client.postUrl( - Uri.parse('$bridgeBaseUrl/acp/rpc'), - ); + final request = await client.postUrl(Uri.parse('$bridgeBaseUrl/acp/rpc')); request.headers.contentType = ContentType.json; - request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bridgeAuthToken'); + request.headers.set( + HttpHeaders.authorizationHeader, + 'Bearer $bridgeAuthToken', + ); request.write( jsonEncode({ 'jsonrpc': '2.0', diff --git a/test/runtime/acp_bridge_provider_hub_suite.dart b/test/runtime/acp_bridge_provider_hub_suite.dart new file mode 100644 index 00000000..c3a3fe2f --- /dev/null +++ b/test/runtime/acp_bridge_provider_hub_suite.dart @@ -0,0 +1,102 @@ +@TestOn('vm') +library; + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/app/app_controller_desktop_external_acp_routing.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +import '../test_support.dart'; + +void main() { + group('ACP bridge provider hub', () { + test( + 'self-hosted ACP bridge base makes builtin single-agent providers visible without per-provider endpoints', + () { + final snapshot = SettingsSnapshot.defaults().copyWith( + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() + .copyWith( + mode: AcpBridgeServerMode.selfHosted, + selfHosted: AcpBridgeServerSelfHostedConfig.defaults().copyWith( + serverUrl: 'https://bridge.example.com', + username: 'review@example.com', + ), + ), + ); + + expect( + snapshot + .externalAcpEndpointForProvider(SingleAgentProvider.codex) + .endpoint, + 'https://bridge.example.com', + ); + expect( + snapshot.savedSingleAgentProviders.map((item) => item.providerId), + contains('opencode'), + ); + }, + ); + + test( + 'builtin provider sync uses bridge base endpoint and self-hosted basic auth when endpoint auth is empty', + () async { + SharedPreferences.setMockInitialValues({}); + final store = createIsolatedTestStore(enableSecureStorage: false); + final controller = AppController(store: store); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + + await controller.settingsController.saveSecretValueByRef( + 'acp_bridge_server_password', + 'top-secret', + provider: 'ACP Bridge Server', + module: 'Settings', + ); + await controller.saveSettings( + controller.settings.copyWith( + acpBridgeServerModeConfig: controller + .settings + .acpBridgeServerModeConfig + .copyWith( + mode: AcpBridgeServerMode.selfHosted, + selfHosted: controller + .settings + .acpBridgeServerModeConfig + .selfHosted + .copyWith( + serverUrl: 'https://bridge.example.com', + username: 'review@example.com', + ), + ), + ), + refreshAfterSave: false, + ); + + final providers = await controller + .buildExternalAcpSyncedProvidersInternal(); + final opencode = providers.firstWhere( + (item) => item.providerId == 'opencode', + ); + + expect(opencode.endpoint, 'https://bridge.example.com'); + expect( + opencode.authorizationHeader, + 'Basic ${base64Encode(utf8.encode('review@example.com:top-secret'))}', + ); + }, + ); + }); +} + +Future _waitFor(bool Function() predicate) async { + final stopwatch = Stopwatch()..start(); + while (!predicate()) { + if (stopwatch.elapsed > const Duration(seconds: 10)) { + throw StateError('Timed out waiting for predicate'); + } + await Future.delayed(const Duration(milliseconds: 50)); + } +} diff --git a/test/runtime/app_controller_assistant_workspace_ref_test.dart b/test/runtime/app_controller_assistant_workspace_ref_test.dart index 3a8fa1dd..117249dc 100644 --- a/test/runtime/app_controller_assistant_workspace_ref_test.dart +++ b/test/runtime/app_controller_assistant_workspace_ref_test.dart @@ -472,7 +472,7 @@ void main() { ); test( - 'AppController keeps the current single-agent thread workspace stable when saving workspace settings', + 'AppController migrates managed single-agent thread workspaces when saving workspace settings', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -524,7 +524,6 @@ void main() { ), derivedBeforeSave, ); - expect(controller.hasSettingsDraftChanges, isTrue); await controller.saveWorkspacePath(workspaceRoot.path); @@ -532,7 +531,7 @@ void main() { controller.assistantWorkspacePathForSession( controller.currentSessionKey, ), - derivedBeforeSave, + '${workspaceRoot.path}/.xworkmate/threads/main', ); expect(controller.hasPendingSettingsApply, isFalse); expect(controller.hasSettingsDraftChanges, isFalse); @@ -540,7 +539,7 @@ void main() { controller .assistantThreadRecordsInternal[controller.currentSessionKey] ?.displayPath, - derivedBeforeSave, + '${workspaceRoot.path}/.xworkmate/threads/main', ); expect( controller @@ -553,7 +552,7 @@ void main() { ); test( - 'AppController rejects missing workspace bindings when reading workspace kind', + 'AppController falls back to local workspace kind when the thread record is missing', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -584,14 +583,15 @@ void main() { await controller.setAssistantExecutionTarget( AssistantExecutionTarget.singleAgent, ); - controller.assistantThreadRecordsInternal.remove( - controller.currentSessionKey, + controller.taskThreadRepositoryInternal.removeWhere( + (sessionKey, _) => sessionKey == controller.currentSessionKey, + persist: false, ); expect( - () => controller.assistantWorkspaceKindForSession( + controller.assistantWorkspaceKindForSession( controller.currentSessionKey, ), - throwsA(isA()), + WorkspaceRefKind.localPath, ); }, ); diff --git a/test/runtime/app_controller_single_agent_workspace_binding_regression_test.dart b/test/runtime/app_controller_single_agent_workspace_binding_regression_test.dart index c8717589..ab8e3d31 100644 --- a/test/runtime/app_controller_single_agent_workspace_binding_regression_test.dart +++ b/test/runtime/app_controller_single_agent_workspace_binding_regression_test.dart @@ -7,43 +7,82 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; -import 'package:xworkmate/runtime/secure_config_store.dart'; import 'app_controller_ai_gateway_chat_suite_fakes.dart'; import 'app_controller_ai_gateway_chat_suite_fixtures.dart'; void main() { - test('single-agent thread upsert auto-binds a complete workspace binding', () async { - final tempDirectory = await createTempDirectoryInternal( - 'xworkmate-single-agent-auto-bind-', - ); - final store = createStoreFromTempDirectoryInternal(tempDirectory); - final controller = await createAppControllerInternal( - store: store, - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.opencode, - ], - runtimeCoordinator: RuntimeCoordinator( - gateway: FakeGatewayRuntimeInternal(store: store), - codex: FakeCodexRuntimeInternal(), - ), - goTaskServiceClient: FallbackOnlyGoTaskServiceClientInternal(), - ); + test( + 'single-agent thread upsert auto-binds a complete workspace binding', + () async { + final tempDirectory = await createTempDirectoryInternal( + 'xworkmate-single-agent-auto-bind-', + ); + final store = createStoreFromTempDirectoryInternal(tempDirectory); + final controller = await createAppControllerInternal( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.opencode, + ], + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + goTaskServiceClient: FallbackOnlyGoTaskServiceClientInternal(), + ); - controller.upsertTaskThreadInternal( - 'main', - singleAgentProvider: SingleAgentProvider.opencode, - singleAgentProviderSource: ThreadSelectionSource.explicit, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - executionTarget: AssistantExecutionTarget.singleAgent, - ); + controller.upsertTaskThreadInternal( + 'main', + singleAgentProvider: SingleAgentProvider.opencode, + singleAgentProviderSource: ThreadSelectionSource.explicit, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + executionTarget: AssistantExecutionTarget.singleAgent, + ); - final workspacePath = controller.assistantWorkspacePathForSession('main'); - expect(workspacePath, isNotEmpty); - expect(Directory(workspacePath).existsSync(), isTrue); - expect( - controller.assistantWorkspaceKindForSession('main'), - WorkspaceRefKind.localPath, - ); - }); + final workspacePath = controller.assistantWorkspacePathForSession('main'); + expect(workspacePath, isNotEmpty); + expect(Directory(workspacePath).existsSync(), isTrue); + expect( + controller.assistantWorkspaceKindForSession('main'), + WorkspaceRefKind.localPath, + ); + }, + ); + + test( + 'single-agent managed thread workspace rebinds when workspace root changes', + () async { + final initialWorkspace = await createTempDirectoryInternal( + 'xworkmate-workspace-initial-', + ); + final nextWorkspace = await createTempDirectoryInternal( + 'xworkmate-workspace-next-', + ); + final store = createStoreFromTempDirectoryInternal(initialWorkspace); + final controller = await createAppControllerInternal( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.opencode, + ], + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + goTaskServiceClient: FallbackOnlyGoTaskServiceClientInternal(), + ); + addTearDown(controller.dispose); + + await controller.saveSettings( + controller.settings.copyWith( + workspacePath: nextWorkspace.path, + assistantExecutionTarget: AssistantExecutionTarget.singleAgent, + ), + refreshAfterSave: false, + ); + + final workspacePath = controller.assistantWorkspacePathForSession('main'); + expect(workspacePath, '${nextWorkspace.path}/.xworkmate/threads/main'); + expect(Directory(workspacePath).existsSync(), isTrue); + }, + ); }