fix single-agent workspace binding

This commit is contained in:
Haitao Pan 2026-04-10 10:18:02 +08:00
parent 5595913c52
commit 6aa69bdf80
7 changed files with 991 additions and 24 deletions

View File

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

View File

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

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

View File

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

View File

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

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

View File

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