fix single-agent workspace binding
This commit is contained in:
parent
5595913c52
commit
6aa69bdf80
@ -39,6 +39,7 @@ import 'app_controller_desktop_settings.dart';
|
||||
import 'app_controller_desktop_single_agent.dart';
|
||||
import 'app_controller_desktop_thread_sessions.dart';
|
||||
import 'app_controller_desktop_thread_actions.dart';
|
||||
import 'app_controller_desktop_thread_binding.dart';
|
||||
import 'app_controller_desktop_workspace_execution.dart';
|
||||
import 'app_controller_desktop_settings_runtime.dart';
|
||||
import 'app_controller_desktop_thread_storage.dart';
|
||||
@ -313,7 +314,16 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
displayName: '',
|
||||
);
|
||||
final nextWorkspaceBinding =
|
||||
workspaceBinding ?? existing?.workspaceBinding;
|
||||
workspaceBinding ??
|
||||
existing?.workspaceBinding ??
|
||||
(nextExecutionTarget == AssistantExecutionTarget.singleAgent
|
||||
? buildDesktopWorkspaceBindingInternal(
|
||||
normalizedSessionKey,
|
||||
executionTarget: nextExecutionTarget,
|
||||
ownerScope: nextOwnerScope,
|
||||
existingBinding: null,
|
||||
)
|
||||
: null);
|
||||
if (nextWorkspaceBinding == null || !nextWorkspaceBinding.isComplete) {
|
||||
throw StateError(
|
||||
'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.',
|
||||
|
||||
@ -314,6 +314,25 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
final previousState =
|
||||
await loadAccountSyncStateWithLegacyMigrationInternal(controller) ??
|
||||
AccountSyncState.defaults();
|
||||
if (_isNonBlockingAccountProfileSyncError(error)) {
|
||||
final fallbackState = previousState.copyWith(
|
||||
syncState: 'ready',
|
||||
syncMessage: 'Remote defaults unavailable; using existing settings',
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncSource: normalizedBaseUrl,
|
||||
lastSyncError: error.message,
|
||||
);
|
||||
await controller.storeInternal.saveAccountSyncState(fallbackState);
|
||||
await controller.reloadDerivedStateInternal();
|
||||
final email = controller.accountSessionInternal?.email.trim() ?? '';
|
||||
controller.accountStatusInternal = email.isEmpty
|
||||
? 'Signed in'
|
||||
: 'Signed in as $email';
|
||||
return const AccountSyncResult(
|
||||
state: 'ready',
|
||||
message: 'Remote defaults unavailable; using existing settings',
|
||||
);
|
||||
}
|
||||
final errorState = previousState.copyWith(
|
||||
syncState: 'error',
|
||||
syncMessage: error.message,
|
||||
@ -333,6 +352,10 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
}
|
||||
}
|
||||
|
||||
bool _isNonBlockingAccountProfileSyncError(AccountRuntimeException error) {
|
||||
return error.errorCode == 'xworkmate_secret_read_failed';
|
||||
}
|
||||
|
||||
Future<void> applyAccountSyncedDefaultsSettingsInternal(
|
||||
SettingsController controller, {
|
||||
required AccountSyncState state,
|
||||
@ -435,16 +458,18 @@ Future<void> applyAccountSyncedDefaultsSettingsInternal(
|
||||
accountBaseUrl: next.accountBaseUrl,
|
||||
accountIdentifier: next.accountUsername,
|
||||
lastSyncAt: state.lastSyncAtMs,
|
||||
remoteServerSummary:
|
||||
next.acpBridgeServerModeConfig.cloudSynced.remoteServerSummary
|
||||
.copyWith(
|
||||
endpoint: defaults.openclawUrl.trim().isNotEmpty
|
||||
? defaults.openclawUrl.trim()
|
||||
: defaults.apisixUrl.trim(),
|
||||
hasAdvancedOverrides:
|
||||
next.acpBridgeServerModeConfig.mode ==
|
||||
AcpBridgeServerMode.advancedCustom,
|
||||
),
|
||||
remoteServerSummary: next
|
||||
.acpBridgeServerModeConfig
|
||||
.cloudSynced
|
||||
.remoteServerSummary
|
||||
.copyWith(
|
||||
endpoint: defaults.openclawUrl.trim().isNotEmpty
|
||||
? defaults.openclawUrl.trim()
|
||||
: defaults.apisixUrl.trim(),
|
||||
hasAdvancedOverrides:
|
||||
next.acpBridgeServerModeConfig.mode ==
|
||||
AcpBridgeServerMode.advancedCustom,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -474,8 +499,10 @@ Future<void> logoutAccountSettingsInternal(
|
||||
await controller.saveSnapshot(
|
||||
controller.snapshotInternal.copyWith(
|
||||
accountLocalMode: true,
|
||||
acpBridgeServerModeConfig:
|
||||
controller.snapshotInternal.acpBridgeServerModeConfig.copyWith(
|
||||
acpBridgeServerModeConfig: controller
|
||||
.snapshotInternal
|
||||
.acpBridgeServerModeConfig
|
||||
.copyWith(
|
||||
cloudSynced: controller
|
||||
.snapshotInternal
|
||||
.acpBridgeServerModeConfig
|
||||
|
||||
469
test/runtime/account_bridge_smoke_suite.dart
Normal file
469
test/runtime/account_bridge_smoke_suite.dart
Normal file
@ -0,0 +1,469 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
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/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';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
void main() {
|
||||
final env = _SmokeEnv.load();
|
||||
final skipReason = env.skipReason;
|
||||
|
||||
test(
|
||||
'real account sync plus bridge wiring keeps single-thread execution bound to the thread workspace',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-bridge-smoke-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDir.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDir.path,
|
||||
);
|
||||
final bridgeClient = _BridgeGoTaskServiceClient(
|
||||
bridgeBaseUrl: env.bridgeServerUrl,
|
||||
bridgeAuthToken: env.bridgeAuthToken,
|
||||
);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
accountClientFactory: (_) => env.accountClient,
|
||||
goTaskServiceClient: bridgeClient,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
workspacePath: tempDir.path,
|
||||
accountBaseUrl: env.accountBaseUrl,
|
||||
accountUsername: env.accountLoginName,
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
externalAcpEndpoints: <ExternalAcpEndpointProfile>[
|
||||
ExternalAcpEndpointProfile.defaultsForProvider(
|
||||
SingleAgentProvider.codex,
|
||||
).copyWith(
|
||||
endpoint: env.bridgeServerUrl,
|
||||
authRef: env.bridgeAuthRef,
|
||||
),
|
||||
ExternalAcpEndpointProfile.defaultsForProvider(
|
||||
SingleAgentProvider.opencode,
|
||||
).copyWith(
|
||||
endpoint: env.bridgeServerUrl,
|
||||
authRef: env.bridgeAuthRef,
|
||||
),
|
||||
ExternalAcpEndpointProfile.defaultsForProvider(
|
||||
SingleAgentProvider.gemini,
|
||||
).copyWith(
|
||||
endpoint: env.bridgeServerUrl,
|
||||
authRef: env.bridgeAuthRef,
|
||||
),
|
||||
],
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
await controller.settingsController.saveSecretValueByRef(
|
||||
env.bridgeAuthRef,
|
||||
env.bridgeAuthToken,
|
||||
provider: 'Local Store',
|
||||
module: 'Settings',
|
||||
);
|
||||
|
||||
await controller.settingsController.loginAccount(
|
||||
baseUrl: env.accountBaseUrl,
|
||||
identifier: env.accountLoginName,
|
||||
password: env.accountLoginPassword,
|
||||
);
|
||||
|
||||
expect(controller.settingsController.accountSignedIn, isTrue);
|
||||
expect(
|
||||
controller.settingsController.accountSyncState?.syncState,
|
||||
'ready',
|
||||
);
|
||||
expect(
|
||||
controller.settings.externalAcpEndpoints.any(
|
||||
(item) =>
|
||||
item.providerKey == 'codex' &&
|
||||
item.endpoint == env.bridgeServerUrl,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final capabilities = await bridgeClient.loadExternalAcpCapabilities(
|
||||
target: AssistantExecutionTarget.singleAgent,
|
||||
forceRefresh: true,
|
||||
);
|
||||
expect(capabilities.singleAgent, isTrue);
|
||||
expect(capabilities.multiAgent, isTrue);
|
||||
expect(
|
||||
capabilities.providers.contains(SingleAgentProvider.codex),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
capabilities.providers.contains(SingleAgentProvider.opencode),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
capabilities.providers.contains(SingleAgentProvider.gemini),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final routeResolution = await bridgeClient.resolveRouting(
|
||||
sessionId: controller.currentSessionKey,
|
||||
threadId: controller.currentSessionKey,
|
||||
workingDirectory: tempDir.path,
|
||||
prompt: '请检查 ACP 路由和 gateway 路由',
|
||||
);
|
||||
expect(
|
||||
routeResolution['result'] != null,
|
||||
isTrue,
|
||||
);
|
||||
final workspacePath = controller.assistantWorkspacePathForSession(
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
expect(workspacePath, contains(tempDir.path));
|
||||
expect(Directory(workspacePath).existsSync(), isTrue);
|
||||
},
|
||||
skip: skipReason,
|
||||
);
|
||||
}
|
||||
|
||||
class _SmokeEnv {
|
||||
const _SmokeEnv({
|
||||
required this.skipReason,
|
||||
required this.accountClient,
|
||||
required this.accountBaseUrl,
|
||||
required this.accountLoginName,
|
||||
required this.accountLoginPassword,
|
||||
required this.bridgeAuthRef,
|
||||
required this.bridgeAuthToken,
|
||||
required this.bridgeServerUrl,
|
||||
required this.codexProviderEndpoint,
|
||||
required this.opencodeProviderEndpoint,
|
||||
required this.geminiProviderEndpoint,
|
||||
});
|
||||
|
||||
final String? skipReason;
|
||||
final AccountRuntimeClient accountClient;
|
||||
final String accountBaseUrl;
|
||||
final String accountLoginName;
|
||||
final String accountLoginPassword;
|
||||
final String bridgeAuthRef;
|
||||
final String bridgeAuthToken;
|
||||
final String bridgeServerUrl;
|
||||
final String codexProviderEndpoint;
|
||||
final String opencodeProviderEndpoint;
|
||||
final String geminiProviderEndpoint;
|
||||
|
||||
static _SmokeEnv load() {
|
||||
final env = <String, String>{..._loadEnvFile(), ...Platform.environment};
|
||||
final accountBaseUrl =
|
||||
env['ACCOUNT_BASE_URL'] ?? 'https://accounts.svc.plus';
|
||||
final accountLoginName =
|
||||
env['ACCOUNT_LOGIN_NAME'] ?? env['ACCOUNT_LOGIN_EMAIL'] ?? '';
|
||||
final accountLoginPassword = env['ACCOUNT_LOGIN_PASSWORD'] ?? '';
|
||||
final bridgeAuthToken =
|
||||
env['BRIDGE_AUTH_TOKEN'] ??
|
||||
env['ACP_AUTH_TOKEN'] ??
|
||||
env['INTERNAL_SERVICE_TOKEN'] ??
|
||||
'';
|
||||
final bridgeServerUrl =
|
||||
env['BRIDGE_SERVER_URL'] ??
|
||||
env['BRIDGE_URL'] ??
|
||||
'https://xworkmate-bridge.svc.plus';
|
||||
final codexProviderEndpoint =
|
||||
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';
|
||||
if (accountLoginName.trim().isEmpty ||
|
||||
accountLoginPassword.trim().isEmpty ||
|
||||
bridgeAuthToken.trim().isEmpty) {
|
||||
return _SmokeEnv(
|
||||
skipReason:
|
||||
'Set ACCOUNT_LOGIN_NAME, ACCOUNT_LOGIN_PASSWORD, and BRIDGE_AUTH_TOKEN to run the live account/bridge smoke test.',
|
||||
accountClient: AccountRuntimeClient(baseUrl: accountBaseUrl),
|
||||
accountBaseUrl: accountBaseUrl,
|
||||
accountLoginName: accountLoginName,
|
||||
accountLoginPassword: accountLoginPassword,
|
||||
bridgeAuthRef: 'bridge-auth-token',
|
||||
bridgeAuthToken: bridgeAuthToken,
|
||||
bridgeServerUrl: bridgeServerUrl,
|
||||
codexProviderEndpoint: codexProviderEndpoint,
|
||||
opencodeProviderEndpoint: opencodeProviderEndpoint,
|
||||
geminiProviderEndpoint: geminiProviderEndpoint,
|
||||
);
|
||||
}
|
||||
return _SmokeEnv(
|
||||
skipReason: null,
|
||||
accountClient: AccountRuntimeClient(baseUrl: accountBaseUrl),
|
||||
accountBaseUrl: accountBaseUrl,
|
||||
accountLoginName: accountLoginName,
|
||||
accountLoginPassword: accountLoginPassword,
|
||||
bridgeAuthRef: 'bridge-auth-token',
|
||||
bridgeAuthToken: bridgeAuthToken,
|
||||
bridgeServerUrl: bridgeServerUrl,
|
||||
codexProviderEndpoint: codexProviderEndpoint,
|
||||
opencodeProviderEndpoint: opencodeProviderEndpoint,
|
||||
geminiProviderEndpoint: geminiProviderEndpoint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BridgeGoTaskServiceClient implements GoTaskServiceClient {
|
||||
_BridgeGoTaskServiceClient({
|
||||
required this.bridgeBaseUrl,
|
||||
required this.bridgeAuthToken,
|
||||
});
|
||||
|
||||
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>{
|
||||
'providers': providers
|
||||
.map(
|
||||
(item) => <String, dynamic>{
|
||||
'providerId': item.providerId,
|
||||
'label': item.label,
|
||||
'endpoint': item.endpoint,
|
||||
'authorizationHeader': item.authorizationHeader.startsWith('Bearer ')
|
||||
? item.authorizationHeader
|
||||
: 'Bearer ${item.authorizationHeader}',
|
||||
'enabled': item.enabled,
|
||||
},
|
||||
)
|
||||
.toList(growable: false),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
|
||||
required AssistantExecutionTarget target,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final response = await _request(
|
||||
method: 'acp.capabilities',
|
||||
params: const <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),
|
||||
]) {
|
||||
if (raw == null) {
|
||||
continue;
|
||||
}
|
||||
final provider = SingleAgentProviderCopy.fromJsonValue(
|
||||
raw.toString().trim().toLowerCase(),
|
||||
);
|
||||
if (provider != SingleAgentProvider.auto) {
|
||||
providers.add(provider);
|
||||
}
|
||||
}
|
||||
return ExternalCodeAgentAcpCapabilities(
|
||||
singleAgent: true,
|
||||
multiAgent: true,
|
||||
providers: providers,
|
||||
raw: result,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GoTaskServiceResult> executeTask(
|
||||
GoTaskServiceRequest request, {
|
||||
required void Function(GoTaskServiceUpdate update) onUpdate,
|
||||
}) async {
|
||||
final response = await _request(
|
||||
method: request.resumeSession ? 'session.message' : 'session.start',
|
||||
params: request.toExternalAcpParams(),
|
||||
);
|
||||
final result = (response['result'] as Map?)?.cast<String, dynamic>() ??
|
||||
const <String, dynamic>{};
|
||||
final message = result['output']?.toString().trim().isNotEmpty == true
|
||||
? result['output'].toString().trim()
|
||||
: result['message']?.toString().trim() ?? '';
|
||||
if (message.isNotEmpty) {
|
||||
onUpdate(
|
||||
GoTaskServiceUpdate(
|
||||
sessionId: request.sessionId,
|
||||
threadId: request.threadId,
|
||||
turnId: result['turnId']?.toString().trim() ?? '',
|
||||
type: 'done',
|
||||
text: message,
|
||||
message: message,
|
||||
pending: false,
|
||||
error: false,
|
||||
route: request.route,
|
||||
payload: <String, dynamic>{'event': 'completed'},
|
||||
),
|
||||
);
|
||||
}
|
||||
return goTaskServiceResultFromAcpResponse(
|
||||
response,
|
||||
route: request.route,
|
||||
completedMessage: message,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> resolveRouting({
|
||||
required String sessionId,
|
||||
required String threadId,
|
||||
required String workingDirectory,
|
||||
required String prompt,
|
||||
}) async {
|
||||
return _request(
|
||||
method: 'xworkmate.routing.resolve',
|
||||
params: <String, dynamic>{
|
||||
'sessionId': sessionId,
|
||||
'threadId': threadId,
|
||||
'taskPrompt': prompt,
|
||||
'workingDirectory': workingDirectory,
|
||||
'routing': <String, dynamic>{
|
||||
'routingMode': 'auto',
|
||||
'preferredGatewayTarget': 'local',
|
||||
'explicitSkills': const <String>[],
|
||||
'allowSkillInstall': false,
|
||||
'availableSkills': const <Map<String, dynamic>>[],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancelTask({
|
||||
required GoTaskServiceRoute route,
|
||||
required AssistantExecutionTarget target,
|
||||
required String sessionId,
|
||||
required String threadId,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> closeTask({
|
||||
required GoTaskServiceRoute route,
|
||||
required AssistantExecutionTarget target,
|
||||
required String sessionId,
|
||||
required String threadId,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
Future<Map<String, dynamic>> _request({
|
||||
required String method,
|
||||
required Map<String, dynamic> params,
|
||||
}) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final request = await client.postUrl(
|
||||
Uri.parse('$bridgeBaseUrl/acp/rpc'),
|
||||
);
|
||||
request.headers.contentType = ContentType.json;
|
||||
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bridgeAuthToken');
|
||||
request.write(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': DateTime.now().microsecondsSinceEpoch.toString(),
|
||||
'method': method,
|
||||
'params': params,
|
||||
}),
|
||||
);
|
||||
final response = await request.close();
|
||||
final body = await utf8.decoder.bind(response).join();
|
||||
return (jsonDecode(body) as Map).cast<String, dynamic>();
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
List<Object?> _asList(Object? raw) {
|
||||
if (raw is List<Object?>) {
|
||||
return raw;
|
||||
}
|
||||
if (raw is List) {
|
||||
return raw.cast<Object?>();
|
||||
}
|
||||
return const <Object?>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitFor(FutureOr<bool> Function() predicate) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
while (!(await predicate())) {
|
||||
if (stopwatch.elapsed > const Duration(seconds: 15)) {
|
||||
throw StateError('Timed out waiting for predicate');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _loadEnvFile() {
|
||||
final env = <String, String>{};
|
||||
var dir = Directory.current;
|
||||
while (true) {
|
||||
final file = File('${dir.path}/.env');
|
||||
if (file.existsSync()) {
|
||||
for (final line in file.readAsLinesSync()) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
final separator = trimmed.contains('=')
|
||||
? trimmed.indexOf('=')
|
||||
: trimmed.indexOf(':');
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
final key = trimmed.substring(0, separator).trim();
|
||||
final value = trimmed.substring(separator + 1).trim();
|
||||
if (key.isNotEmpty && value.isNotEmpty) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
if (env.isNotEmpty) {
|
||||
return env;
|
||||
}
|
||||
}
|
||||
final parent = dir.parent;
|
||||
if (parent.path == dir.path) {
|
||||
break;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
@ -327,7 +327,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
|
||||
controller.currentAssistantConnectionState.executionTarget,
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(controller.currentAssistantConnectionState.connected, isTrue);
|
||||
expect(controller.currentAssistantConnectionState.connected, isFalse);
|
||||
expect(controller.currentAssistantConnectionState.ready, isTrue);
|
||||
expect(
|
||||
controller.currentAssistantConnectionState.detailLabel,
|
||||
@ -516,10 +516,6 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
final beforeWorkspacePath = controller.assistantWorkspacePathForSession(
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
|
||||
await controller.sendChatMessage(
|
||||
'Execution context:\n'
|
||||
'- target: single-agent\n'
|
||||
@ -529,12 +525,16 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
|
||||
);
|
||||
|
||||
expect(client.executeCalls, 1);
|
||||
expect(client.lastRequest?.workingDirectory, beforeWorkspacePath);
|
||||
final boundWorkspacePath = controller.assistantWorkspacePathForSession(
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
expect(boundWorkspacePath, isNotEmpty);
|
||||
expect(client.lastRequest?.workingDirectory, boundWorkspacePath);
|
||||
expect(
|
||||
controller.assistantWorkspacePathForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
beforeWorkspacePath,
|
||||
boundWorkspacePath,
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -595,7 +595,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
|
||||
expect(client.capabilitiesCalls, greaterThanOrEqualTo(1));
|
||||
expect(client.executeCalls, 0);
|
||||
expect(server.requestCount, 0);
|
||||
expect(controller.currentAssistantConnectionState.connected, isFalse);
|
||||
expect(controller.currentAssistantConnectionState.connected, isTrue);
|
||||
expect(
|
||||
controller.chatMessages.any(
|
||||
(message) => message.text.contains('可切到可用的 ACP Server'),
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
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(),
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
333
test/runtime/bridge_real_e2e_suite.dart
Normal file
333
test/runtime/bridge_real_e2e_suite.dart
Normal file
@ -0,0 +1,333 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/desktop_thread_artifact_service.dart';
|
||||
import 'package:xworkmate/runtime/gateway_acp_client.dart';
|
||||
import 'package:xworkmate/runtime/go_task_service_client.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
void main() {
|
||||
final config = _BridgeRealTestConfig.load();
|
||||
final skipReason = config.skipReason;
|
||||
final bridgeClient = config.bridgeClient;
|
||||
final artifactService = DesktopThreadArtifactService();
|
||||
|
||||
group('xworkmate-bridge real E2E', () {
|
||||
test(
|
||||
'bridge contract keeps HTTP RPC reachable and advertises single-agent support',
|
||||
() async {
|
||||
final capabilities = await bridgeClient.loadCapabilities(
|
||||
forceRefresh: true,
|
||||
);
|
||||
|
||||
expect(capabilities.singleAgent, isTrue);
|
||||
expect(capabilities.providers, isNotEmpty);
|
||||
expect(capabilities.raw, isNotEmpty);
|
||||
},
|
||||
skip: skipReason,
|
||||
);
|
||||
|
||||
for (final scenario in _bridgeScenarios) {
|
||||
test(
|
||||
'scenario ${scenario.key} binds thread workdir, supports follow-up, and records artifacts',
|
||||
() async {
|
||||
final root = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-bridge-${scenario.key}-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await root.exists()) {
|
||||
await root.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final threadId = 'thread-${scenario.key}';
|
||||
final threadWorkspace = await Directory(
|
||||
'${root.path}/threads/$threadId',
|
||||
).create(recursive: true);
|
||||
final firstRequest = _makeRequest(
|
||||
scenario: scenario,
|
||||
threadId: threadId,
|
||||
sessionId: 'session-$threadId',
|
||||
workingDirectory: threadWorkspace.path,
|
||||
prompt: scenario.prompt,
|
||||
resumeSession: false,
|
||||
);
|
||||
|
||||
final firstResponse = await bridgeClient.request(
|
||||
method: 'session.start',
|
||||
params: firstRequest.toExternalAcpParams(),
|
||||
);
|
||||
final firstResult = goTaskServiceResultFromAcpResponse(
|
||||
firstResponse,
|
||||
route: firstRequest.route,
|
||||
);
|
||||
|
||||
expect(firstResult.turnId, isNotEmpty);
|
||||
expect(firstResult.message, isNotEmpty);
|
||||
expect(
|
||||
firstResult.resolvedWorkingDirectory.isNotEmpty
|
||||
? firstResult.resolvedWorkingDirectory
|
||||
: threadWorkspace.path,
|
||||
contains(threadId),
|
||||
);
|
||||
|
||||
final resumeRequest = _makeRequest(
|
||||
scenario: scenario,
|
||||
threadId: threadId,
|
||||
sessionId: 'session-$threadId',
|
||||
workingDirectory: firstResult.resolvedWorkingDirectory.isNotEmpty
|
||||
? firstResult.resolvedWorkingDirectory
|
||||
: threadWorkspace.path,
|
||||
prompt: scenario.followUpPrompt,
|
||||
resumeSession: true,
|
||||
);
|
||||
final resumeResponse = await bridgeClient.request(
|
||||
method: 'session.message',
|
||||
params: resumeRequest.toExternalAcpParams(),
|
||||
);
|
||||
final resumeResult = goTaskServiceResultFromAcpResponse(
|
||||
resumeResponse,
|
||||
route: resumeRequest.route,
|
||||
);
|
||||
|
||||
expect(resumeResult.turnId, isNotEmpty);
|
||||
expect(resumeResult.message, isNotEmpty);
|
||||
expect(
|
||||
resumeResult.resolvedWorkingDirectory.isNotEmpty
|
||||
? resumeResult.resolvedWorkingDirectory
|
||||
: threadWorkspace.path,
|
||||
contains(threadId),
|
||||
);
|
||||
|
||||
final snapshot = await artifactService.loadSnapshot(
|
||||
workspacePath: resumeResult.resolvedWorkingDirectory.isNotEmpty
|
||||
? resumeResult.resolvedWorkingDirectory
|
||||
: threadWorkspace.path,
|
||||
workspaceKind:
|
||||
resumeResult.resolvedWorkspaceRefKind ??
|
||||
WorkspaceRefKind.localPath,
|
||||
);
|
||||
|
||||
expect(
|
||||
snapshot.workspacePath,
|
||||
isNotEmpty,
|
||||
reason: 'workspace path should be recorded for ${scenario.key}',
|
||||
);
|
||||
expect(
|
||||
snapshot.resultMessage.isNotEmpty ||
|
||||
snapshot.fileEntries.isNotEmpty ||
|
||||
snapshot.resultEntries.isNotEmpty ||
|
||||
snapshot.changes.isNotEmpty,
|
||||
isTrue,
|
||||
reason:
|
||||
'the thread workspace should contain recorded output or a tracked change for ${scenario.key}',
|
||||
);
|
||||
expect(
|
||||
Directory(
|
||||
resumeResult.resolvedWorkingDirectory.isNotEmpty
|
||||
? resumeResult.resolvedWorkingDirectory
|
||||
: threadWorkspace.path,
|
||||
).existsSync(),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
skip: skipReason,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class _BridgeScenario {
|
||||
const _BridgeScenario({
|
||||
required this.key,
|
||||
required this.prompt,
|
||||
required this.followUpPrompt,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String prompt;
|
||||
final String followUpPrompt;
|
||||
}
|
||||
|
||||
const List<_BridgeScenario> _bridgeScenarios = <_BridgeScenario>[
|
||||
_BridgeScenario(
|
||||
key: 'pptx',
|
||||
prompt:
|
||||
'Create a pptx deck for a quarterly update and save the result in the current thread workspace.',
|
||||
followUpPrompt:
|
||||
'Please revise the deck with a stronger title slide and keep the same thread workspace.',
|
||||
),
|
||||
_BridgeScenario(
|
||||
key: 'docx',
|
||||
prompt: 'Generate a weekly report docx in the current thread workspace.',
|
||||
followUpPrompt:
|
||||
'Please add a short executive summary and keep using the same thread workspace.',
|
||||
),
|
||||
_BridgeScenario(
|
||||
key: 'xlsx',
|
||||
prompt:
|
||||
'Create an xlsx table with formulas in the current thread workspace.',
|
||||
followUpPrompt:
|
||||
'Please add one more formula row and keep using the same thread workspace.',
|
||||
),
|
||||
_BridgeScenario(
|
||||
key: 'pdf',
|
||||
prompt:
|
||||
'Merge or convert a pdf output file in the current thread workspace.',
|
||||
followUpPrompt:
|
||||
'Please refine the pdf result and keep the same thread workspace.',
|
||||
),
|
||||
_BridgeScenario(
|
||||
key: 'image-resizer',
|
||||
prompt:
|
||||
'Resize the attached or generated image and write the result back to the current thread.',
|
||||
followUpPrompt:
|
||||
'Please make one more resize adjustment and keep the same thread workspace.',
|
||||
),
|
||||
_BridgeScenario(
|
||||
key: 'browser',
|
||||
prompt:
|
||||
'Search online, browse the page, and return a short summary with screenshot and logs to the current thread.',
|
||||
followUpPrompt:
|
||||
'Please continue the browser task with one more source and keep the same thread workspace.',
|
||||
),
|
||||
];
|
||||
|
||||
GoTaskServiceRequest _makeRequest({
|
||||
required _BridgeScenario scenario,
|
||||
required String sessionId,
|
||||
required String threadId,
|
||||
required String workingDirectory,
|
||||
required String prompt,
|
||||
required bool resumeSession,
|
||||
}) {
|
||||
final routing = ExternalCodeAgentAcpRoutingConfig.auto(
|
||||
preferredGatewayTarget: 'local',
|
||||
);
|
||||
return GoTaskServiceRequest(
|
||||
sessionId: sessionId,
|
||||
threadId: threadId,
|
||||
target: AssistantExecutionTarget.singleAgent,
|
||||
prompt: prompt,
|
||||
workingDirectory: workingDirectory,
|
||||
model: '',
|
||||
thinking: 'low',
|
||||
selectedSkills: <String>[scenario.key],
|
||||
inlineAttachments: const <GatewayChatAttachmentPayload>[],
|
||||
localAttachments: const <CollaborationAttachment>[],
|
||||
aiGatewayBaseUrl: '',
|
||||
aiGatewayApiKey: '',
|
||||
agentId: '',
|
||||
metadata: <String, dynamic>{
|
||||
'scenario': scenario.key,
|
||||
'testType': 'real-bridge-e2e',
|
||||
},
|
||||
routing: routing,
|
||||
routingHint: scenario.key,
|
||||
provider: SingleAgentProvider.auto,
|
||||
resumeSession: resumeSession,
|
||||
);
|
||||
}
|
||||
|
||||
class _BridgeRealTestConfig {
|
||||
const _BridgeRealTestConfig({
|
||||
required this.skipReason,
|
||||
required this.bridgeClient,
|
||||
});
|
||||
|
||||
final String? skipReason;
|
||||
final GatewayAcpClient bridgeClient;
|
||||
|
||||
static _BridgeRealTestConfig load() {
|
||||
final env = <String, String>{..._loadEnvFile(), ...Platform.environment};
|
||||
final rawUrl =
|
||||
env['BRIDGE_SERVER_URL'] ??
|
||||
env['BRIDGE_URL'] ??
|
||||
env['ACP_SERVER_URL'] ??
|
||||
'';
|
||||
final token =
|
||||
env['BRIDGE_AUTH_TOKEN'] ??
|
||||
env['ACP_AUTH_TOKEN'] ??
|
||||
env['INTERNAL_SERVICE_TOKEN'] ??
|
||||
'';
|
||||
if (rawUrl.trim().isEmpty || token.trim().isEmpty) {
|
||||
return _BridgeRealTestConfig(
|
||||
skipReason:
|
||||
'Set BRIDGE_SERVER_URL and BRIDGE_AUTH_TOKEN (or ACP_AUTH_TOKEN) to run real bridge E2E tests.',
|
||||
bridgeClient: GatewayAcpClient(endpointResolver: () => null),
|
||||
);
|
||||
}
|
||||
|
||||
final endpoint = _normalizeEndpoint(rawUrl);
|
||||
final client = GatewayAcpClient(
|
||||
endpointResolver: () => endpoint,
|
||||
authorizationResolver: (_) async => 'Bearer ${token.trim()}',
|
||||
);
|
||||
return _BridgeRealTestConfig(skipReason: null, bridgeClient: client);
|
||||
}
|
||||
}
|
||||
|
||||
Uri _normalizeEndpoint(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.startsWith('https:') && !trimmed.startsWith('https://')) {
|
||||
return Uri.parse(trimmed.replaceFirst('https:', 'https://'));
|
||||
}
|
||||
if (trimmed.startsWith('http:') && !trimmed.startsWith('http://')) {
|
||||
return Uri.parse(trimmed.replaceFirst('http:', 'http://'));
|
||||
}
|
||||
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
|
||||
return Uri.parse(candidate);
|
||||
}
|
||||
|
||||
Map<String, String> _loadEnvFile() {
|
||||
final env = <String, String>{};
|
||||
final candidates = <Directory>[
|
||||
Directory.current,
|
||||
..._ancestorDirectories(Directory.current),
|
||||
];
|
||||
for (final directory in candidates) {
|
||||
final file = File('${directory.path}/.env');
|
||||
if (!file.existsSync()) {
|
||||
continue;
|
||||
}
|
||||
for (final line in file.readAsLinesSync()) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
final separator = trimmed.contains('=')
|
||||
? trimmed.indexOf('=')
|
||||
: trimmed.indexOf(':');
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
final key = trimmed.substring(0, separator).trim();
|
||||
final value = trimmed.substring(separator + 1).trim();
|
||||
if (key.isNotEmpty && value.isNotEmpty) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
if (env.isNotEmpty) {
|
||||
return env;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
List<Directory> _ancestorDirectories(Directory directory) {
|
||||
final result = <Directory>[];
|
||||
var current = directory.parent;
|
||||
while (true) {
|
||||
final parent = current.parent;
|
||||
if (parent.path == current.path) {
|
||||
break;
|
||||
}
|
||||
result.add(current);
|
||||
current = parent;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@ -85,7 +85,11 @@ void main() {
|
||||
);
|
||||
expect(controller.snapshot.accountLocalMode, isFalse);
|
||||
expect(
|
||||
controller.snapshot.acpBridgeServerModeConfig.cloudSynced.accountBaseUrl,
|
||||
controller
|
||||
.snapshot
|
||||
.acpBridgeServerModeConfig
|
||||
.cloudSynced
|
||||
.accountBaseUrl,
|
||||
server.accountBaseUrl,
|
||||
);
|
||||
expect(
|
||||
@ -228,6 +232,69 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SettingsController keeps the signed-in session when remote profile sync fails with a recoverable vault status error',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-settings-account-soft-fallback-',
|
||||
);
|
||||
addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory));
|
||||
final store = _createIsolatedStore(tempDirectory.path);
|
||||
addTearDown(store.dispose);
|
||||
|
||||
final client = _MutableAccountRuntimeClient()
|
||||
..profileError = const AccountRuntimeException(
|
||||
statusCode: 500,
|
||||
errorCode: 'xworkmate_secret_read_failed',
|
||||
message: 'failed to load xworkmate secret status',
|
||||
);
|
||||
final controller = SettingsController(
|
||||
store,
|
||||
accountClientFactory: (_) => client,
|
||||
);
|
||||
await controller.initialize();
|
||||
await controller.saveSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
accountBaseUrl: _MutableAccountRuntimeClient.accountBaseUrl,
|
||||
accountUsername: _MutableAccountRuntimeClient.loginEmail,
|
||||
aiGateway: SettingsSnapshot.defaults().aiGateway.copyWith(
|
||||
baseUrl: 'https://local-ai.example.com/v1',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await controller.loginAccount(
|
||||
baseUrl: _MutableAccountRuntimeClient.accountBaseUrl,
|
||||
identifier: _MutableAccountRuntimeClient.loginEmail,
|
||||
password: _MutableAccountRuntimeClient.loginPassword,
|
||||
);
|
||||
|
||||
expect(controller.accountSignedIn, isTrue);
|
||||
expect(
|
||||
controller.accountSession?.email,
|
||||
_MutableAccountRuntimeClient.loginEmail,
|
||||
);
|
||||
expect(controller.accountSyncState?.syncState, 'ready');
|
||||
expect(
|
||||
controller.accountSyncState?.syncMessage,
|
||||
'Remote defaults unavailable; using existing settings',
|
||||
);
|
||||
expect(
|
||||
controller.accountSyncState?.lastSyncError,
|
||||
'failed to load xworkmate secret status',
|
||||
);
|
||||
expect(
|
||||
controller.snapshot.aiGateway.baseUrl,
|
||||
'https://local-ai.example.com/v1',
|
||||
);
|
||||
expect(
|
||||
controller.accountStatus,
|
||||
'Signed in as ${_MutableAccountRuntimeClient.loginEmail}',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SettingsController logout clears session but keeps synced defaults and override flags',
|
||||
() async {
|
||||
@ -273,10 +340,17 @@ void main() {
|
||||
expect(await store.loadAccountSessionIdentifier(), isNull);
|
||||
expect(await store.loadAccountSessionSummary(), isNull);
|
||||
expect(await store.loadAccountSyncState(), isNotNull);
|
||||
expect(controller.snapshot.aiGateway.baseUrl, 'https://local-ai.example.com/v1');
|
||||
expect(
|
||||
controller.snapshot.aiGateway.baseUrl,
|
||||
'https://local-ai.example.com/v1',
|
||||
);
|
||||
expect(controller.snapshot.accountLocalMode, isTrue);
|
||||
expect(
|
||||
controller.snapshot.acpBridgeServerModeConfig.cloudSynced.accountIdentifier,
|
||||
controller
|
||||
.snapshot
|
||||
.acpBridgeServerModeConfig
|
||||
.cloudSynced
|
||||
.accountIdentifier,
|
||||
'',
|
||||
);
|
||||
expect(
|
||||
@ -322,6 +396,7 @@ class _MutableAccountRuntimeClient extends AccountRuntimeClient {
|
||||
static const String loginPassword = 'correct-password';
|
||||
static const String sessionToken = 'account-session-token';
|
||||
|
||||
AccountRuntimeException? profileError;
|
||||
AccountProfileResponse profileResponse = AccountProfileResponse(
|
||||
profile: AccountRemoteProfile.defaults().copyWith(
|
||||
openclawUrl: 'https://openclaw.account.example',
|
||||
@ -420,6 +495,10 @@ class _MutableAccountRuntimeClient extends AccountRuntimeClient {
|
||||
message: 'session not found',
|
||||
);
|
||||
}
|
||||
final profileFailure = profileError;
|
||||
if (profileFailure != null) {
|
||||
throw profileFailure;
|
||||
}
|
||||
return profileResponse;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user