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 不变
- `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` 成为统一请求语义

View File

@ -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.

View File

@ -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 默认值运转”写成长期主设计

View File

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

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

View File

@ -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,
)) {

View File

@ -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) {

View File

@ -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',

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(
'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,
);
},
);

View File

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