Align bridge core path and secure account sync

This commit is contained in:
Haitao Pan 2026-04-10 11:55:36 +08:00
parent 4caff8506b
commit 74daa6461d
12 changed files with 320 additions and 116 deletions

View File

@ -18,7 +18,8 @@ Last Updated: 2026-04-08
- UI 不变 - UI 不变
- `GoTaskService.executeTask` 是唯一公开入口 - `GoTaskService.executeTask` 是唯一公开入口
- ACP 是统一控制面 - ACP 是统一控制面
- `single-agent / multi-agent / gateway` 是 ACP 解析后的执行器分支 - `bridge` 是 app 客户端的发现 / 配置 / 连接 / 对话枢纽
- 账户同步只同步 bridge 相关配置属性与安全引用,不做自动连接
## 目标态 ## 目标态
@ -32,13 +33,11 @@ flowchart TD
F --> G["Memory.Inject"] F --> G["Memory.Inject"]
G --> H["buildResolvedExecutionParams"] G --> H["buildResolvedExecutionParams"]
H --> I{"resolvedExecutionTarget"} H --> I{"resolvedExecutionTarget"}
I -->|"single-agent"| J["single-agent executor"] I -->|"single-agent"| J["single-agent ACP request"]
I -->|"multi-agent"| K["multi-agent executor"] I -->|"multi-agent"| K["multi-agent ACP request"]
I -->|"gateway"| L["gateway executor"] J --> M["bridge hub"]
J --> M["Adapter Layer"]
K --> M K --> M
L --> M M --> N["Gateway / Provider adapters"]
M --> N["External Runtime / Relay / Gateway"]
N --> O["stream events / result"] N --> O["stream events / result"]
O --> P["Memory.Record"] O --> P["Memory.Record"]
P --> Q["Update Thread State"] P --> Q["Update Thread State"]
@ -51,7 +50,8 @@ flowchart TD
- Desktop App 直接桥接 Go 代码 - Desktop App 直接桥接 Go 代码
- Desktop 正常执行链路不以“先启动一个本地 HTTP server再由 Desktop 自己回连”作为目标架构 - Desktop 正常执行链路不以“先启动一个本地 HTTP server再由 Desktop 自己回连”作为目标架构
- Desktop 的 `sendMessage -> GoTaskService.executeTask -> ACP` 应理解为进程内或直接桥接语义,不是 Web server 回环语义 - Desktop 的 `sendMessage -> GoTaskService.executeTask -> ACP` 应理解为进程内或直接桥接语义
- 对 app 来说bridge 是 discovery / config / connect / dialogue 的统一枢纽
### Web / Mobile ### Web / Mobile
@ -99,5 +99,5 @@ flowchart TD
1. 文档口径收敛 1. 文档口径收敛
2. Dart 请求模型统一 2. Dart 请求模型统一
3. route 决策内收到 `GoTaskService` / ACP 3. route 决策内收到 `GoTaskService` / ACP
4. `gateway` 成为 ACP executor 4. app 侧 bridge 枢纽与 provider / gateway 适配关系收敛
5. `multi-agent` 成为统一请求语义 5. `multi-agent` 成为统一请求语义

View File

@ -4,7 +4,7 @@
The ACP Bridge Server implementation was migrated out of `xworkmate-app` into the standalone sibling repository `xworkmate-bridge`. 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 ## New Repository
@ -33,9 +33,9 @@ The following app-side concerns remain in `xworkmate-app`:
## Build Contract ## 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 ## App Repository Changes
@ -57,3 +57,5 @@ Validated during migration:
## Operational Note ## 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. 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.

View File

@ -11,8 +11,9 @@ Last Updated: 2026-04-08
- `TaskThread` 是线程控制面 - `TaskThread` 是线程控制面
- `GoTaskService.executeTask` 是唯一公开执行入口 - `GoTaskService.executeTask` 是唯一公开执行入口
- ACP 是统一控制面 - ACP 是统一控制面
- `single-agent / multi-agent / gateway` 是 ACP 解析后的执行器分支 - `bridge` 是 app 客户端侧的发现 / 配置 / 连接 / 对话枢纽
- 兼容旁路不再作为架构目标 - 账户同步只同步 bridge 相关配置属性与安全引用,不负责自动连接
- 历史旁路与旧的直连叙述不再作为目标架构
## 总览图 ## 总览图
@ -51,11 +52,11 @@ flowchart TB
E5["buildResolvedExecutionParams"] E5["buildResolvedExecutionParams"]
end end
subgraph L6["Executors / Adapters"] subgraph L6["Bridge / Executors / Adapters"]
F1["single-agent executor"] F1["single-agent ACP request"]
F2["multi-agent executor"] F2["multi-agent ACP request"]
F3["gateway executor"] F3["bridge hub<br/>discovery / config / connect / dialogue"]
F4["GatewayRuntime / Web relay / GatewayAcpClient"] F4["gateway / provider adapters"]
end end
A1 --> B1 A1 --> B1
@ -77,7 +78,8 @@ flowchart TB
E4 --> E5 E4 --> E5
E5 --> F1 E5 --> F1
E5 --> F2 E5 --> F2
E5 --> F3 F1 --> F3
F2 --> F3
F3 --> F4 F3 --> F4
``` ```
@ -87,24 +89,25 @@ flowchart TB
2. `TaskThread` 承载线程级事实,不由页面局部状态拼装。 2. `TaskThread` 承载线程级事实,不由页面局部状态拼装。
3. `GoTaskService.executeTask` 是唯一公开任务入口。 3. `GoTaskService.executeTask` 是唯一公开任务入口。
4. ACP 是统一控制面,负责 routing / skills / memory / resolved execution。 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` 公开语义不再是目标架构的一部分 - 旧的 `openClawTask` 公开语义不再是目标架构的一部分
- `GatewayRuntime`、`Web relay`、`GatewayAcpClient` 只作为 adapter/executor 能力存在 - 不再把“客户端直接围绕旧 gateway 默认值运转”写成长期主设计

View File

@ -41,23 +41,43 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
buildExternalAcpSyncedProvidersInternal() async { buildExternalAcpSyncedProvidersInternal() async {
final providers = <ExternalCodeAgentAcpSyncedProvider>[]; final providers = <ExternalCodeAgentAcpSyncedProvider>[];
for (final profile in settings.externalAcpEndpoints) { for (final profile in settings.externalAcpEndpoints) {
final providerId = profile.providerKey.trim(); final builtinProvider = profile.builtinProvider;
final endpoint = profile.endpoint.trim(); final effectiveProfile = builtinProvider == null
? profile
: settings.externalAcpEndpointForProvider(builtinProvider);
final providerId = effectiveProfile.providerKey.trim();
final endpoint = effectiveProfile.endpoint.trim();
if (providerId.isEmpty || endpoint.isEmpty) { if (providerId.isEmpty || endpoint.isEmpty) {
continue; continue;
} }
final authorizationHeader = profile.authRef.trim().isEmpty var authorizationHeader = effectiveProfile.authRef.trim().isEmpty
? '' ? ''
: await settingsControllerInternal.resolveSecretValueInternal( : 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( providers.add(
ExternalCodeAgentAcpSyncedProvider( ExternalCodeAgentAcpSyncedProvider(
providerId: providerId, providerId: providerId,
label: profile.label, label: effectiveProfile.label,
endpoint: endpoint, endpoint: endpoint,
authorizationHeader: authorizationHeader, authorizationHeader: authorizationHeader,
enabled: profile.enabled, enabled: effectiveProfile.enabled,
), ),
); );
} }

View File

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

View File

@ -44,6 +44,7 @@ import 'app_controller_desktop_settings_runtime.dart';
import 'app_controller_desktop_thread_storage.dart'; import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.dart'; import 'app_controller_desktop_skill_permissions.dart';
import 'app_controller_desktop_runtime_coordination_impl.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 // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
extension AppControllerDesktopRuntimeHelpers on AppController { extension AppControllerDesktopRuntimeHelpers on AppController {
@ -758,7 +759,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
RuntimeConnectionMode mode, RuntimeConnectionMode mode,
) { ) {
return switch (mode) { return switch (mode) {
RuntimeConnectionMode.unconfigured => AssistantExecutionTarget.singleAgent, RuntimeConnectionMode.unconfigured =>
AssistantExecutionTarget.singleAgent,
RuntimeConnectionMode.local => AssistantExecutionTarget.local, RuntimeConnectionMode.local => AssistantExecutionTarget.local,
RuntimeConnectionMode.remote => AssistantExecutionTarget.remote, 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;
}

View File

@ -46,6 +46,23 @@ import 'app_controller_desktop_skill_permissions.dart';
import 'app_controller_desktop_runtime_helpers.dart'; import 'app_controller_desktop_runtime_helpers.dart';
extension AppControllerDesktopThreadBinding on AppController { 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) { String localThreadWorkspacePathInternal(String sessionKey) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal( final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey, sessionKey,
@ -161,6 +178,10 @@ extension AppControllerDesktopThreadBinding on AppController {
if (executionTarget == AssistantExecutionTarget.singleAgent) { if (executionTarget == AssistantExecutionTarget.singleAgent) {
if (existingBinding != null && if (existingBinding != null &&
existingBinding.workspaceKind == WorkspaceKind.localFs && existingBinding.workspaceKind == WorkspaceKind.localFs &&
!isManagedLocalThreadWorkspacePathInternal(
existingBinding.workspacePath,
sessionKey,
) &&
ensureLocalWorkspaceDirectoryInternal( ensureLocalWorkspaceDirectoryInternal(
existingBinding.workspacePath, existingBinding.workspacePath,
)) { )) {

View File

@ -516,8 +516,21 @@ class SettingsSnapshot {
ExternalAcpEndpointProfile externalAcpEndpointForProvider( ExternalAcpEndpointProfile externalAcpEndpointForProvider(
SingleAgentProvider provider, SingleAgentProvider provider,
) { ) {
return externalAcpEndpointForProviderId(provider.providerId) ?? final profile =
externalAcpEndpointForProviderId(provider.providerId) ??
ExternalAcpEndpointProfile.defaultsForProvider(provider); 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( ExternalAcpEndpointProfile? externalAcpEndpointForProviderId(
@ -591,9 +604,17 @@ class SettingsSnapshot {
List<SingleAgentProvider> get savedSingleAgentProviders => List<SingleAgentProvider> get savedSingleAgentProviders =>
normalizeSingleAgentProviderList( normalizeSingleAgentProviderList(
externalAcpEndpoints externalAcpEndpoints.map((item) {
.where((item) => item.enabled && item.endpoint.trim().isNotEmpty) final provider = item.toProvider();
.map((item) => item.toProvider()), if (provider.isAuto) {
return null;
}
final effective = externalAcpEndpointForProvider(provider);
if (!effective.enabled || effective.endpoint.trim().isEmpty) {
return null;
}
return effective.toProvider();
}).whereType<SingleAgentProvider>(),
); );
bool isGatewayTargetSaved(AssistantExecutionTarget target) { bool isGatewayTargetSaved(AssistantExecutionTarget target) {

View File

@ -9,7 +9,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/runtime/account_runtime_client.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/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_controllers.dart'; import 'package:xworkmate/runtime/runtime_controllers.dart';
import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/runtime_models.dart';
@ -130,10 +129,7 @@ void main() {
workingDirectory: tempDir.path, workingDirectory: tempDir.path,
prompt: '请检查 ACP 路由和 gateway 路由', prompt: '请检查 ACP 路由和 gateway 路由',
); );
expect( expect(routeResolution['result'] != null, isTrue);
routeResolution['result'] != null,
isTrue,
);
final workspacePath = controller.assistantWorkspacePathForSession( final workspacePath = controller.assistantWorkspacePathForSession(
controller.currentSessionKey, controller.currentSessionKey,
); );
@ -188,14 +184,12 @@ class _SmokeEnv {
env['BRIDGE_URL'] ?? env['BRIDGE_URL'] ??
'https://xworkmate-bridge.svc.plus'; 'https://xworkmate-bridge.svc.plus';
final codexProviderEndpoint = final codexProviderEndpoint =
env['CODEX_PROVIDER_ENDPOINT'] ?? env['CODEX_PROVIDER_ENDPOINT'] ?? 'https://acp-server.svc.plus/codex';
'https://acp-server.svc.plus/codex';
final opencodeProviderEndpoint = final opencodeProviderEndpoint =
env['OPENCODE_PROVIDER_ENDPOINT'] ?? env['OPENCODE_PROVIDER_ENDPOINT'] ??
'https://acp-server.svc.plus/opencode'; 'https://acp-server.svc.plus/opencode';
final geminiProviderEndpoint = final geminiProviderEndpoint =
env['GEMINI_PROVIDER_ENDPOINT'] ?? env['GEMINI_PROVIDER_ENDPOINT'] ?? 'https://acp-server.svc.plus/gemini';
'https://acp-server.svc.plus/gemini';
if (accountLoginName.trim().isEmpty || if (accountLoginName.trim().isEmpty ||
accountLoginPassword.trim().isEmpty || accountLoginPassword.trim().isEmpty ||
bridgeAuthToken.trim().isEmpty) { bridgeAuthToken.trim().isEmpty) {
@ -238,16 +232,11 @@ class _BridgeGoTaskServiceClient implements GoTaskServiceClient {
final String bridgeBaseUrl; final String bridgeBaseUrl;
final String bridgeAuthToken; final String bridgeAuthToken;
List<ExternalCodeAgentAcpSyncedProvider> _providers =
const <ExternalCodeAgentAcpSyncedProvider>[];
@override @override
Future<void> syncExternalProviders( Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers, List<ExternalCodeAgentAcpSyncedProvider> providers,
) async { ) async {
_providers = List<ExternalCodeAgentAcpSyncedProvider>.unmodifiable(
providers,
);
await _request( await _request(
method: 'xworkmate.providers.sync', method: 'xworkmate.providers.sync',
params: <String, dynamic>{ params: <String, dynamic>{
@ -257,7 +246,8 @@ class _BridgeGoTaskServiceClient implements GoTaskServiceClient {
'providerId': item.providerId, 'providerId': item.providerId,
'label': item.label, 'label': item.label,
'endpoint': item.endpoint, 'endpoint': item.endpoint,
'authorizationHeader': item.authorizationHeader.startsWith('Bearer ') 'authorizationHeader':
item.authorizationHeader.startsWith('Bearer ')
? item.authorizationHeader ? item.authorizationHeader
: 'Bearer ${item.authorizationHeader}', : 'Bearer ${item.authorizationHeader}',
'enabled': item.enabled, 'enabled': item.enabled,
@ -277,14 +267,17 @@ class _BridgeGoTaskServiceClient implements GoTaskServiceClient {
method: 'acp.capabilities', method: 'acp.capabilities',
params: const <String, dynamic>{}, params: const <String, dynamic>{},
); );
final result = (response['result'] as Map?)?.cast<String, dynamic>() ?? final result =
(response['result'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{}; const <String, dynamic>{};
final providers = <SingleAgentProvider>{}; final providers = <SingleAgentProvider>{};
for (final raw in <Object?>[ for (final raw in <Object?>[
..._asList(result['providers']), ..._asList(result['providers']),
..._asList(result['capabilities'] is Map ..._asList(
? (result['capabilities'] as Map)['providers'] result['capabilities'] is Map
: null), ? (result['capabilities'] as Map)['providers']
: null,
),
]) { ]) {
if (raw == null) { if (raw == null) {
continue; continue;
@ -313,7 +306,8 @@ class _BridgeGoTaskServiceClient implements GoTaskServiceClient {
method: request.resumeSession ? 'session.message' : 'session.start', method: request.resumeSession ? 'session.message' : 'session.start',
params: request.toExternalAcpParams(), params: request.toExternalAcpParams(),
); );
final result = (response['result'] as Map?)?.cast<String, dynamic>() ?? final result =
(response['result'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{}; const <String, dynamic>{};
final message = result['output']?.toString().trim().isNotEmpty == true final message = result['output']?.toString().trim().isNotEmpty == true
? result['output'].toString().trim() ? result['output'].toString().trim()
@ -390,11 +384,12 @@ class _BridgeGoTaskServiceClient implements GoTaskServiceClient {
}) async { }) async {
final client = HttpClient(); final client = HttpClient();
try { try {
final request = await client.postUrl( final request = await client.postUrl(Uri.parse('$bridgeBaseUrl/acp/rpc'));
Uri.parse('$bridgeBaseUrl/acp/rpc'),
);
request.headers.contentType = ContentType.json; request.headers.contentType = ContentType.json;
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bridgeAuthToken'); request.headers.set(
HttpHeaders.authorizationHeader,
'Bearer $bridgeAuthToken',
);
request.write( request.write(
jsonEncode(<String, dynamic>{ jsonEncode(<String, dynamic>{
'jsonrpc': '2.0', 'jsonrpc': '2.0',

View File

@ -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(<String, Object>{});
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<void> _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<void>.delayed(const Duration(milliseconds: 50));
}
}

View File

@ -472,7 +472,7 @@ void main() {
); );
test( 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 { () async {
SharedPreferences.setMockInitialValues(<String, Object>{}); SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp( final tempDirectory = await Directory.systemTemp.createTemp(
@ -524,7 +524,6 @@ void main() {
), ),
derivedBeforeSave, derivedBeforeSave,
); );
expect(controller.hasSettingsDraftChanges, isTrue);
await controller.saveWorkspacePath(workspaceRoot.path); await controller.saveWorkspacePath(workspaceRoot.path);
@ -532,7 +531,7 @@ void main() {
controller.assistantWorkspacePathForSession( controller.assistantWorkspacePathForSession(
controller.currentSessionKey, controller.currentSessionKey,
), ),
derivedBeforeSave, '${workspaceRoot.path}/.xworkmate/threads/main',
); );
expect(controller.hasPendingSettingsApply, isFalse); expect(controller.hasPendingSettingsApply, isFalse);
expect(controller.hasSettingsDraftChanges, isFalse); expect(controller.hasSettingsDraftChanges, isFalse);
@ -540,7 +539,7 @@ void main() {
controller controller
.assistantThreadRecordsInternal[controller.currentSessionKey] .assistantThreadRecordsInternal[controller.currentSessionKey]
?.displayPath, ?.displayPath,
derivedBeforeSave, '${workspaceRoot.path}/.xworkmate/threads/main',
); );
expect( expect(
controller controller
@ -553,7 +552,7 @@ void main() {
); );
test( test(
'AppController rejects missing workspace bindings when reading workspace kind', 'AppController falls back to local workspace kind when the thread record is missing',
() async { () async {
SharedPreferences.setMockInitialValues(<String, Object>{}); SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp( final tempDirectory = await Directory.systemTemp.createTemp(
@ -584,14 +583,15 @@ void main() {
await controller.setAssistantExecutionTarget( await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent, AssistantExecutionTarget.singleAgent,
); );
controller.assistantThreadRecordsInternal.remove( controller.taskThreadRepositoryInternal.removeWhere(
controller.currentSessionKey, (sessionKey, _) => sessionKey == controller.currentSessionKey,
persist: false,
); );
expect( expect(
() => controller.assistantWorkspaceKindForSession( controller.assistantWorkspaceKindForSession(
controller.currentSessionKey, controller.currentSessionKey,
), ),
throwsA(isA<StateError>()), WorkspaceRefKind.localPath,
); );
}, },
); );

View File

@ -7,43 +7,82 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_models.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_fakes.dart';
import 'app_controller_ai_gateway_chat_suite_fixtures.dart'; import 'app_controller_ai_gateway_chat_suite_fixtures.dart';
void main() { void main() {
test('single-agent thread upsert auto-binds a complete workspace binding', () async { test(
final tempDirectory = await createTempDirectoryInternal( 'single-agent thread upsert auto-binds a complete workspace binding',
'xworkmate-single-agent-auto-bind-', () async {
); final tempDirectory = await createTempDirectoryInternal(
final store = createStoreFromTempDirectoryInternal(tempDirectory); 'xworkmate-single-agent-auto-bind-',
final controller = await createAppControllerInternal( );
store: store, final store = createStoreFromTempDirectoryInternal(tempDirectory);
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[ final controller = await createAppControllerInternal(
SingleAgentProvider.opencode, store: store,
], availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
runtimeCoordinator: RuntimeCoordinator( SingleAgentProvider.opencode,
gateway: FakeGatewayRuntimeInternal(store: store), ],
codex: FakeCodexRuntimeInternal(), runtimeCoordinator: RuntimeCoordinator(
), gateway: FakeGatewayRuntimeInternal(store: store),
goTaskServiceClient: FallbackOnlyGoTaskServiceClientInternal(), codex: FakeCodexRuntimeInternal(),
); ),
goTaskServiceClient: FallbackOnlyGoTaskServiceClientInternal(),
);
controller.upsertTaskThreadInternal( controller.upsertTaskThreadInternal(
'main', 'main',
singleAgentProvider: SingleAgentProvider.opencode, singleAgentProvider: SingleAgentProvider.opencode,
singleAgentProviderSource: ThreadSelectionSource.explicit, singleAgentProviderSource: ThreadSelectionSource.explicit,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
executionTarget: AssistantExecutionTarget.singleAgent, executionTarget: AssistantExecutionTarget.singleAgent,
); );
final workspacePath = controller.assistantWorkspacePathForSession('main'); final workspacePath = controller.assistantWorkspacePathForSession('main');
expect(workspacePath, isNotEmpty); expect(workspacePath, isNotEmpty);
expect(Directory(workspacePath).existsSync(), isTrue); expect(Directory(workspacePath).existsSync(), isTrue);
expect( expect(
controller.assistantWorkspaceKindForSession('main'), controller.assistantWorkspaceKindForSession('main'),
WorkspaceRefKind.localPath, 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>[
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);
},
);
} }