Align bridge core path and secure account sync
This commit is contained in:
parent
4caff8506b
commit
74daa6461d
@ -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` 成为统一请求语义
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<br/>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 默认值运转”写成长期主设计
|
||||
|
||||
@ -41,23 +41,43 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
|
||||
buildExternalAcpSyncedProvidersInternal() async {
|
||||
final providers = <ExternalCodeAgentAcpSyncedProvider>[];
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
14
lib/app/app_controller_desktop_runtime_exceptions.dart
Normal file
14
lib/app/app_controller_desktop_runtime_exceptions.dart
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)) {
|
||||
|
||||
@ -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<SingleAgentProvider> 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<SingleAgentProvider>(),
|
||||
);
|
||||
|
||||
bool isGatewayTargetSaved(AssistantExecutionTarget target) {
|
||||
|
||||
@ -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<ExternalCodeAgentAcpSyncedProvider> _providers =
|
||||
const <ExternalCodeAgentAcpSyncedProvider>[];
|
||||
|
||||
@override
|
||||
Future<void> syncExternalProviders(
|
||||
List<ExternalCodeAgentAcpSyncedProvider> providers,
|
||||
) async {
|
||||
_providers = List<ExternalCodeAgentAcpSyncedProvider>.unmodifiable(
|
||||
providers,
|
||||
);
|
||||
await _request(
|
||||
method: 'xworkmate.providers.sync',
|
||||
params: <String, dynamic>{
|
||||
@ -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 <String, dynamic>{},
|
||||
);
|
||||
final result = (response['result'] as Map?)?.cast<String, dynamic>() ??
|
||||
final result =
|
||||
(response['result'] as Map?)?.cast<String, dynamic>() ??
|
||||
const <String, dynamic>{};
|
||||
final providers = <SingleAgentProvider>{};
|
||||
for (final raw in <Object?>[
|
||||
..._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<String, dynamic>() ??
|
||||
final result =
|
||||
(response['result'] as Map?)?.cast<String, dynamic>() ??
|
||||
const <String, dynamic>{};
|
||||
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(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
|
||||
102
test/runtime/acp_bridge_provider_hub_suite.dart
Normal file
102
test/runtime/acp_bridge_provider_hub_suite.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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(<String, Object>{});
|
||||
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(<String, Object>{});
|
||||
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<StateError>()),
|
||||
WorkspaceRefKind.localPath,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -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>[
|
||||
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>[
|
||||
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>[
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user