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 不变
|
- 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` 成为统一请求语义
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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 默认值运转”写成长期主设计
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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_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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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,
|
||||||
)) {
|
)) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
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(
|
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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user