refactor: remove silent local gateway fallback

This commit is contained in:
Haitao Pan 2026-04-11 08:49:46 +08:00
parent b77a486d2c
commit 288da17a47
14 changed files with 783 additions and 124 deletions

View File

@ -72,7 +72,8 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
.toList(growable: false);
final resolvedExplicitProviderId =
thread?.hasExplicitProviderSelection ?? false
sessionTarget == AssistantExecutionTarget.singleAgent &&
(thread?.hasExplicitProviderSelection ?? false)
? singleAgentProviderForSession(normalizedSessionKey).providerId
: '';
final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false

View File

@ -103,6 +103,7 @@ Future<void> refreshSingleAgentCapabilitiesRuntimeInternal(
AppController controller, {
bool forceRefresh = false,
}) async {
await controller.syncExternalAcpProvidersInternal();
final capabilities = await controller.goTaskServiceClientInternal
.loadExternalAcpCapabilities(
target: AssistantExecutionTarget.singleAgent,

View File

@ -27,6 +27,7 @@ import '../runtime/codex_config_bridge.dart';
import '../runtime/code_agent_node_orchestrator.dart';
import '../runtime/assistant_artifacts.dart';
import '../runtime/desktop_thread_artifact_service.dart';
import '../runtime/go_task_service_client.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_orchestrator.dart';
@ -712,6 +713,104 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
return resolveSingleAgentAuthorizationHeaderInternal(endpoint);
}
Future<List<ExternalCodeAgentAcpSyncedProvider>>
buildExternalAcpSyncedProvidersInternal() async {
final providers = <ExternalCodeAgentAcpSyncedProvider>[];
for (final profile in settings.externalAcpEndpoints) {
final provider = settings.singleAgentProviderForId(profile.providerKey);
if (provider == SingleAgentProvider.auto) {
continue;
}
final endpoint = profile.endpoint.trim();
if (!profile.enabled || endpoint.isEmpty) {
continue;
}
final authorizationHeader = profile.authRef.trim().isEmpty
? ''
: await settingsControllerInternal.resolveSecretValueInternal(
refName: profile.authRef.trim(),
);
providers.add(
ExternalCodeAgentAcpSyncedProvider(
providerId: provider.providerId,
label: provider.label,
endpoint: endpoint,
authorizationHeader: authorizationHeader,
enabled: true,
),
);
}
return providers;
}
Future<void> syncExternalAcpProvidersInternal() async {
await goTaskServiceClientInternal.syncExternalProviders(
await buildExternalAcpSyncedProvidersInternal(),
);
}
Future<void> persistGoTaskArtifactsForSessionInternal(
String sessionKey,
GoTaskServiceResult result,
) async {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final artifacts = result.artifacts;
final syncedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble();
if (artifacts.isEmpty) {
upsertTaskThreadInternal(
normalizedSessionKey,
lastArtifactSyncAtMs: syncedAtMs,
lastArtifactSyncStatus: 'no-artifacts',
updatedAtMs: syncedAtMs,
);
return;
}
final existingThread = requireTaskThreadForSessionInternal(
normalizedSessionKey,
);
if (existingThread.workspaceBinding.workspaceKind !=
WorkspaceKind.localFs) {
upsertTaskThreadInternal(
normalizedSessionKey,
lastArtifactSyncAtMs: syncedAtMs,
lastArtifactSyncStatus: 'skipped-non-local-workspace',
updatedAtMs: syncedAtMs,
);
return;
}
final root = Directory(existingThread.workspaceBinding.workspacePath);
await root.create(recursive: true);
var wroteArtifact = false;
for (final artifact in artifacts) {
if (!artifact.hasInlineContent) {
continue;
}
final relativePath = _sanitizeArtifactRelativePathInternal(
artifact.relativePath,
);
if (relativePath.isEmpty) {
continue;
}
final target = await _nextArtifactTargetFileInternal(root, relativePath);
await target.parent.create(recursive: true);
await target.writeAsBytes(
_decodeArtifactContentInternal(artifact),
flush: true,
);
wroteArtifact = true;
}
upsertTaskThreadInternal(
normalizedSessionKey,
lastArtifactSyncAtMs: syncedAtMs,
lastArtifactSyncStatus: wroteArtifact ? 'synced' : 'no-inline-content',
updatedAtMs: syncedAtMs,
);
}
Uri? resolveGatewayAcpEndpointInternal() {
final target = assistantExecutionTargetForSession(
sessionsControllerInternal.currentSessionKey,
@ -821,3 +920,51 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
};
}
}
String _sanitizeArtifactRelativePathInternal(String raw) {
final trimmed = raw.trim().replaceAll('\\', '/');
if (trimmed.isEmpty) {
return '';
}
return trimmed
.split('/')
.where(
(segment) => segment.isNotEmpty && segment != '.' && segment != '..',
)
.join('/');
}
List<int> _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) {
final encoding = artifact.encoding.trim().toLowerCase();
if (encoding == 'base64') {
return base64Decode(artifact.content);
}
return utf8.encode(artifact.content);
}
Future<File> _nextArtifactTargetFileInternal(
Directory root,
String relativePath,
) async {
final segments = relativePath.split('/');
final fileName = segments.removeLast();
final parent = segments.isEmpty
? root
: Directory('${root.path}/${segments.join('/')}');
final dotIndex = fileName.lastIndexOf('.');
final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex);
final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex);
var candidate = File('${parent.path}/$fileName');
if (!await candidate.exists()) {
return candidate;
}
for (var version = 2; version < 1000; version += 1) {
candidate = File('${parent.path}/$baseName.v$version$extension');
if (!await candidate.exists()) {
return candidate;
}
}
return File(
'${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension',
);
}

View File

@ -1,8 +1,6 @@
// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
@ -153,6 +151,7 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
.map((item) => item.label.trim().isNotEmpty ? item.label : item.key)
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
await controller.syncExternalAcpProvidersInternal();
final result = await controller.goTaskServiceClientInternal.executeTask(
GoTaskServiceRequest(
sessionId: sessionKey,
@ -340,105 +339,4 @@ Future<void> _persistSingleAgentArtifactsDesktopInternal(
AppController controller,
String sessionKey,
GoTaskServiceResult result,
) async {
final artifacts = result.artifacts;
if (artifacts.isEmpty) {
controller.upsertTaskThreadInternal(
sessionKey,
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastArtifactSyncStatus: 'no-artifacts',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
return;
}
final existingThread = controller.requireTaskThreadForSessionInternal(
sessionKey,
);
if (existingThread.workspaceBinding.workspaceKind != WorkspaceKind.localFs) {
controller.upsertTaskThreadInternal(
sessionKey,
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastArtifactSyncStatus: 'skipped-non-local-workspace',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
return;
}
final root = Directory(existingThread.workspaceBinding.workspacePath);
await root.create(recursive: true);
var wroteArtifact = false;
for (final artifact in artifacts) {
if (!artifact.hasInlineContent) {
continue;
}
final relativePath = _sanitizeArtifactRelativePathInternal(
artifact.relativePath,
);
if (relativePath.isEmpty) {
continue;
}
final target = await _nextArtifactTargetFileInternal(root, relativePath);
await target.parent.create(recursive: true);
await target.writeAsBytes(
_decodeArtifactContentInternal(artifact),
flush: true,
);
wroteArtifact = true;
}
controller.upsertTaskThreadInternal(
sessionKey,
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastArtifactSyncStatus: wroteArtifact ? 'synced' : 'no-inline-content',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
}
String _sanitizeArtifactRelativePathInternal(String raw) {
final trimmed = raw.trim().replaceAll('\\', '/');
if (trimmed.isEmpty) {
return '';
}
final cleaned = trimmed
.split('/')
.where(
(segment) => segment.isNotEmpty && segment != '.' && segment != '..',
)
.join('/');
return cleaned;
}
List<int> _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) {
final encoding = artifact.encoding.trim().toLowerCase();
if (encoding == 'base64') {
return base64Decode(artifact.content);
}
return utf8.encode(artifact.content);
}
Future<File> _nextArtifactTargetFileInternal(
Directory root,
String relativePath,
) async {
final segments = relativePath.split('/');
final fileName = segments.removeLast();
final parent = segments.isEmpty
? root
: Directory('${root.path}/${segments.join('/')}');
final dotIndex = fileName.lastIndexOf('.');
final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex);
final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex);
var candidate = File('${parent.path}/$fileName');
if (!await candidate.exists()) {
return candidate;
}
for (var version = 2; version < 1000; version += 1) {
candidate = File('${parent.path}/$baseName.v$version$extension');
if (!await candidate.exists()) {
return candidate;
}
}
return File(
'${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension',
);
}
) => controller.persistGoTaskArtifactsForSessionInternal(sessionKey, result);

View File

@ -334,10 +334,18 @@ extension AppControllerDesktopSkillPermissions on AppController {
);
}
final nextProvider =
singleAgentProvider ??
SingleAgentProviderCopy.fromJsonValue(
executionBinding?.providerId ?? existing?.executionBinding.providerId,
);
nextExecutionTarget == AssistantExecutionTarget.singleAgent
? (singleAgentProvider ??
SingleAgentProviderCopy.fromJsonValue(
executionBinding?.providerId ??
existing?.executionBinding.providerId,
))
: SingleAgentProvider.auto;
final nextProviderSource =
nextExecutionTarget == AssistantExecutionTarget.singleAgent
? (singleAgentProviderSource ??
existing?.executionBinding.providerSource)
: ThreadSelectionSource.inherited;
final nextExecutionBinding =
(executionBinding ??
existing?.executionBinding ??
@ -361,9 +369,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
executionModeSource:
executionTargetSource ??
existing?.executionBinding.executionModeSource,
providerSource:
singleAgentProviderSource ??
existing?.executionBinding.providerSource,
providerSource: nextProviderSource,
);
final nextContextState =
(contextState ??

View File

@ -306,6 +306,7 @@ extension AppControllerDesktopThreadActions on AppController {
try {
final dispatch = await codeAgentNodeOrchestratorInternal
.buildGatewayDispatch(buildCodeAgentNodeStateInternal());
await syncExternalAcpProvidersInternal();
final result = await goTaskServiceClientInternal.executeTask(
GoTaskServiceRequest(
sessionId: sessionKey,
@ -347,6 +348,7 @@ extension AppControllerDesktopThreadActions on AppController {
lastResultCode: result.success ? 'success' : 'error',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await persistGoTaskArtifactsForSessionInternal(sessionKey, result);
if (!result.success) {
appendLocalSessionMessageInternal(
sessionKey,

View File

@ -255,9 +255,10 @@ extension AppControllerDesktopThreadBinding on AppController {
required SingleAgentProvider singleAgentProvider,
ExecutionBinding? existingBinding,
}) {
final sanitizedProvider = settings.sanitizeSingleAgentProviderSelection(
singleAgentProvider,
);
final sanitizedProvider =
executionTarget == AssistantExecutionTarget.singleAgent
? settings.sanitizeSingleAgentProviderSelection(singleAgentProvider)
: SingleAgentProvider.auto;
return (existingBinding ??
ExecutionBinding(
executionMode: ThreadExecutionMode.localAgent,
@ -275,6 +276,10 @@ extension AppControllerDesktopThreadBinding on AppController {
},
executorId: sanitizedProvider.providerId,
providerId: sanitizedProvider.providerId,
providerSource:
executionTarget == AssistantExecutionTarget.singleAgent
? existingBinding?.providerSource
: ThreadSelectionSource.inherited,
);
}

View File

@ -160,6 +160,7 @@ Future<void> runMultiAgentCollaborationThreadSessionInternal(
);
controller.recomputeTasksInternal();
try {
await controller.syncExternalAcpProvidersInternal();
final result = await controller.goTaskServiceClientInternal.executeTask(
GoTaskServiceRequest(
sessionId: sessionKey,
@ -203,6 +204,10 @@ Future<void> runMultiAgentCollaborationThreadSessionInternal(
);
},
);
await controller.persistGoTaskArtifactsForSessionInternal(
sessionKey,
result,
);
controller.appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(

View File

@ -444,7 +444,7 @@ extension AppControllerDesktopThreadStorage on AppController {
path: resolvedRootPath,
bookmark: rootSpec.bookmark,
),
);
);
if (accessHandle == null) {
continue;
}
@ -690,11 +690,14 @@ extension AppControllerDesktopThreadStorage on AppController {
final recordExecutionTarget = assistantExecutionTargetFromExecutionMode(
record.executionBinding.executionMode,
);
final recordProvider = settings.sanitizeSingleAgentProviderSelection(
SingleAgentProviderCopy.fromJsonValue(
record.executionBinding.providerId,
),
);
final recordProvider =
recordExecutionTarget == AssistantExecutionTarget.singleAgent
? settings.sanitizeSingleAgentProviderSelection(
SingleAgentProviderCopy.fromJsonValue(
record.executionBinding.providerId,
),
)
: SingleAgentProvider.auto;
final workspaceBinding = record.workspaceBinding.copyWith(
workspaceId: sessionKey,
displayPath: record.workspaceKind == WorkspaceKind.localFs
@ -728,6 +731,10 @@ extension AppControllerDesktopThreadStorage on AppController {
),
executorId: recordProvider.providerId,
providerId: recordProvider.providerId,
providerSource:
recordExecutionTarget == AssistantExecutionTarget.singleAgent
? record.executionBinding.providerSource
: ThreadSelectionSource.inherited,
),
lifecycleState: record.lifecycleState.copyWith(status: 'ready'),
);

View File

@ -276,6 +276,9 @@ class MobileShellStateInternal extends State<MobileShell> {
);
return;
}
if (!mounted) {
return;
}
final codeController = TextEditingController();
final enteredCode = await showDialog<String>(
context: context,

View File

@ -127,12 +127,12 @@ AssistantExecutionTarget resolveGatewayExecutionTargetFromVisibleTargets(
visible.contains(currentTarget)) {
return currentTarget;
}
if (visible.contains(AssistantExecutionTarget.local)) {
return AssistantExecutionTarget.local;
}
if (visible.contains(AssistantExecutionTarget.remote)) {
return AssistantExecutionTarget.remote;
}
if (visible.contains(AssistantExecutionTarget.local)) {
return AssistantExecutionTarget.local;
}
return AssistantExecutionTarget.remote;
}

View File

@ -1,9 +1,25 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller_desktop_core.dart';
import 'package:xworkmate/app/app_controller_desktop_external_acp_routing.dart';
import 'package:xworkmate/app/app_controller_desktop_runtime_helpers.dart';
import 'package:xworkmate/app/app_controller_desktop_skill_permissions.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_binding.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart';
import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart';
import 'package:xworkmate/runtime/codex_config_bridge.dart';
import 'package:xworkmate/runtime/codex_runtime.dart';
import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('resolveDesktopThreadBindingSnapshotInternal', () {
const localOwner = ThreadOwnerScope(
realm: ThreadRealm.local,
@ -92,6 +108,33 @@ void main() {
});
});
group('resolveGatewayExecutionTargetFromVisibleTargets', () {
test('prefers remote bridge target over silent local fallback', () {
final target = resolveGatewayExecutionTargetFromVisibleTargets(
const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
],
currentTarget: AssistantExecutionTarget.singleAgent,
);
expect(target, AssistantExecutionTarget.remote);
});
test('preserves explicit local gateway selection when already active', () {
final target = resolveGatewayExecutionTargetFromVisibleTargets(
const <AssistantExecutionTarget>[
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
],
currentTarget: AssistantExecutionTarget.local,
);
expect(target, AssistantExecutionTarget.local);
});
});
group('resolveGatewayThreadConnectionStateInternal', () {
test('uses the thread target profile as the only address source', () {
final state = resolveGatewayThreadConnectionStateInternal(
@ -130,4 +173,199 @@ void main() {
expect(state.lastError, isNull);
});
});
group('assistantConnectionStateForSession', () {
test(
'uses target profile address instead of connection snapshot address',
() {
final gateway = _FakeGatewayRuntime(
GatewayConnectionSnapshot.initial(
mode: RuntimeConnectionMode.remote,
).copyWith(
status: RuntimeConnectionStatus.connected,
remoteAddress: '127.0.0.1:18789',
),
);
final controller = AppController(
runtimeCoordinator: RuntimeCoordinator(
gateway: gateway,
codex: CodexRuntime(),
configBridge: CodexConfigBridge(),
),
);
addTearDown(() async {
controller.dispose();
await gateway.disposeTestResources();
});
const sessionKey = 'draft:remote-status';
controller.initializeAssistantThreadContext(
sessionKey,
executionTarget: AssistantExecutionTarget.remote,
);
controller.upsertTaskThreadInternal(
sessionKey,
executionTarget: AssistantExecutionTarget.remote,
executionTargetSource: ThreadSelectionSource.explicit,
);
final state = controller.assistantConnectionStateForSession(sessionKey);
expect(state.status, RuntimeConnectionStatus.connected);
expect(state.detailLabel, 'openclaw.svc.plus:443');
expect(state.ready, isTrue);
},
);
});
group('buildExternalAcpRoutingForSessionInternal', () {
test('never emits explicit provider id for gateway threads', () {
final controller = AppController();
addTearDown(controller.dispose);
const sessionKey = 'draft:routing';
controller.initializeAssistantThreadContext(
sessionKey,
executionTarget: AssistantExecutionTarget.remote,
singleAgentProvider: SingleAgentProvider.opencode,
);
controller.upsertTaskThreadInternal(
sessionKey,
executionTarget: AssistantExecutionTarget.remote,
executionTargetSource: ThreadSelectionSource.explicit,
singleAgentProvider: SingleAgentProvider.opencode,
singleAgentProviderSource: ThreadSelectionSource.explicit,
);
final routing = controller.buildExternalAcpRoutingForSessionInternal(
sessionKey,
);
expect(routing.mode, ExternalCodeAgentAcpRoutingMode.explicit);
expect(routing.explicitExecutionTarget, 'remote');
expect(routing.explicitProviderId, isEmpty);
});
});
group('persistGoTaskArtifactsForSessionInternal', () {
test(
'writes bridge-returned artifacts into the local thread workspace',
() async {
final root = Directory.systemTemp.createTempSync(
'xworkmate-thread-artifacts-test-',
);
final controller = AppController();
addTearDown(() async {
controller.dispose();
if (root.existsSync()) {
await root.delete(recursive: true);
}
});
const sessionKey = 'draft:remote-artifacts';
controller.upsertTaskThreadInternal(
sessionKey,
executionTarget: AssistantExecutionTarget.remote,
executionTargetSource: ThreadSelectionSource.explicit,
workspaceBinding: WorkspaceBinding(
workspaceId: 'workspace-1',
workspaceKind: WorkspaceKind.localFs,
workspacePath: root.path,
displayPath: root.path,
writable: true,
),
);
await controller.persistGoTaskArtifactsForSessionInternal(
sessionKey,
GoTaskServiceResult(
success: true,
message: 'ok',
turnId: 'turn-1',
raw: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': '../notes/result.md',
'encoding': 'utf-8',
'content': 'artifact-body',
},
<String, dynamic>{
'relativePath': 'bin/data.txt',
'encoding': 'base64',
'content': 'YmluYXJ5LWRhdGE=',
},
],
},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
expect(
File('${root.path}/notes/result.md').readAsStringSync(),
'artifact-body',
);
expect(
File('${root.path}/bin/data.txt').readAsStringSync(),
'binary-data',
);
final record = controller.requireTaskThreadForSessionInternal(
sessionKey,
);
expect(record.lastArtifactSyncStatus, 'synced');
expect(record.lastArtifactSyncAtMs, isNotNull);
},
);
});
}
class _FakeGatewayRuntime extends GatewayRuntime {
factory _FakeGatewayRuntime(GatewayConnectionSnapshot snapshot) {
final deps = _FakeGatewayRuntimeDeps();
return _FakeGatewayRuntime._(snapshot, deps);
}
_FakeGatewayRuntime._(this._snapshot, this._deps)
: super(store: _deps.store, identityStore: _deps.identityStore);
final GatewayConnectionSnapshot _snapshot;
final _FakeGatewayRuntimeDeps _deps;
@override
GatewayConnectionSnapshot get snapshot => _snapshot;
@override
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
@override
Future<void> initialize() async {}
Future<void> disposeTestResources() async {
if (_deps.root.existsSync()) {
await _deps.root.delete(recursive: true);
}
}
}
class _FakeGatewayRuntimeDeps {
factory _FakeGatewayRuntimeDeps() {
final root = Directory.systemTemp.createTempSync(
'xworkmate-gateway-runtime-test-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${root.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => root.path,
defaultSupportDirectoryPathResolver: () async => root.path,
);
return _FakeGatewayRuntimeDeps._(root, store, DeviceIdentityStore(store));
}
_FakeGatewayRuntimeDeps._(this.root, this.store, this.identityStore);
final Directory root;
final SecureConfigStore store;
final DeviceIdentityStore identityStore;
}

View File

@ -0,0 +1,228 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller_desktop_core.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_bar.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart';
import 'package:xworkmate/runtime/desktop_platform_service.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/runtime/skill_directory_access.dart';
import 'package:xworkmate/theme/app_theme.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'compact gateway picker selects remote bridge route instead of local fallback',
(tester) async {
final root = Directory.systemTemp.createTempSync(
'xworkmate-picker-widget-test-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${root.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => root.path,
defaultSupportDirectoryPathResolver: () async => root.path,
);
final controller = AppController(
store: store,
desktopPlatformService: UnsupportedDesktopPlatformService(),
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(
root.path,
),
goTaskServiceClient: const _FakeGoTaskServiceClient(),
singleAgentSharedSkillScanRootOverrides: const <String>[],
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
);
final inputController = TextEditingController();
final focusNode = FocusNode();
addTearDown(() async {
controller.dispose();
inputController.dispose();
focusNode.dispose();
if (root.existsSync()) {
await root.delete(recursive: true);
}
});
controller.settingsController.snapshotInternal = controller.settings
.copyWith(savedGatewayTargets: const <String>['local', 'remote']);
controller.lastObservedSettingsSnapshotInternal =
controller.settingsController.snapshotInternal;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(platform: TargetPlatform.macOS),
home: Scaffold(
body: ComposerBarInternal(
controller: controller,
inputController: inputController,
focusNode: focusNode,
thinkingLabel: 'Normal',
showModelControl: false,
modelLabel: '',
modelOptions: const <String>[],
attachments: const <ComposerAttachmentInternal>[],
availableSkills: const <ComposerSkillOptionInternal>[],
selectedSkillKeys: const <String>[],
onRemoveAttachment: (_) {},
onToggleSkill: (_) {},
onThinkingChanged: (_) {},
onModelChanged: (_) async {},
onOpenGateway: () {},
onOpenAiGatewaySettings: () {},
onReconnectGateway: () async {},
onPickAttachments: () {},
onAddAttachment: (_) {},
onPasteImageAttachment: () async => null,
onContentHeightChanged: (_) {},
onInputHeightChanged: (_) {},
onSend: () async {},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(
controller.assistantExecutionTarget,
AssistantExecutionTarget.singleAgent,
);
final buttonFinder = find.byKey(
const Key('assistant-execution-target-button'),
);
expect(buttonFinder, findsOneWidget);
final button = tester.widget<PopupMenuButton<AssistantExecutionTarget>>(
buttonFinder,
);
final items = button.itemBuilder(tester.element(buttonFinder));
final values = items
.whereType<PopupMenuItem<AssistantExecutionTarget>>()
.map((item) => item.value)
.toList(growable: false);
expect(values, contains(AssistantExecutionTarget.remote));
expect(values, isNot(contains(AssistantExecutionTarget.local)));
await tester.pumpWidget(const SizedBox.shrink());
controller.dispose();
await tester.pump();
},
);
}
class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService {
const _FakeSkillDirectoryAccessService(this.homeDirectory);
final String homeDirectory;
@override
bool get isSupported => false;
@override
Future<List<AuthorizedSkillDirectory>> authorizeDirectories({
List<String> suggestedPaths = const <String>[],
}) async {
return const <AuthorizedSkillDirectory>[];
}
@override
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
}) async {
return null;
}
@override
Future<SkillDirectoryAccessHandle?> openDirectory(
AuthorizedSkillDirectory directory,
) async {
return null;
}
@override
Future<String> resolveUserHomeDirectory() async {
return homeDirectory;
}
}
class _FakeGoTaskServiceClient implements GoTaskServiceClient {
const _FakeGoTaskServiceClient();
@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 {}
@override
Future<GoTaskServiceResult> executeTask(
GoTaskServiceRequest request, {
required void Function(GoTaskServiceUpdate update) onUpdate,
}) async {
return const GoTaskServiceResult(
success: true,
message: '',
turnId: '',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
);
}
@override
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
required String taskPrompt,
required String workingDirectory,
required ExternalCodeAgentAcpRoutingConfig routing,
String aiGatewayBaseUrl = '',
String aiGatewayApiKey = '',
}) async {
return const ExternalCodeAgentAcpRoutingResolution(
raw: <String, dynamic>{
'resolvedExecutionTarget': 'single-agent',
'resolvedEndpointTarget': 'singleAgent',
'resolvedProviderId': 'codex',
'resolvedModel': '',
'resolvedSkills': <String>[],
'unavailable': false,
},
);
}
@override
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
required AssistantExecutionTarget target,
bool forceRefresh = false,
}) async {
return const ExternalCodeAgentAcpCapabilities.empty();
}
@override
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
) async {}
}

View File

@ -0,0 +1,118 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/external_code_agent_acp_desktop_transport.dart';
import 'package:xworkmate/runtime/go_acp_stdio_bridge.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
class _FakeGoAcpStdioBridgeWithSyncOrder extends GoAcpStdioBridge {
final List<String> methods = <String>[];
final StreamController<Map<String, dynamic>> _notifications =
StreamController<Map<String, dynamic>>.broadcast();
@override
Stream<Map<String, dynamic>> get notifications => _notifications.stream;
@override
Future<Map<String, dynamic>> request({
required String method,
required Map<String, dynamic> params,
Duration timeout = const Duration(seconds: 120),
}) async {
methods.add(method);
return switch (method) {
'acp.capabilities' => <String, dynamic>{
'result': <String, dynamic>{
'singleAgent': true,
'multiAgent': true,
'providers': <String>['codex'],
},
},
_ => <String, dynamic>{
'result': <String, dynamic>{
'success': true,
'output': 'ok',
'resolvedExecutionTarget': 'single-agent',
},
},
};
}
@override
Future<void> dispose() async {
await _notifications.close();
}
}
void main() {
group('External ACP bridge sync order', () {
test('syncs providers before capabilities requests', () async {
final bridge = _FakeGoAcpStdioBridgeWithSyncOrder();
final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge);
await transport
.syncExternalProviders(const <ExternalCodeAgentAcpSyncedProvider>[
ExternalCodeAgentAcpSyncedProvider(
providerId: 'codex',
label: 'Codex',
endpoint: 'https://acp-server.svc.plus/codex/acp/rpc',
authorizationHeader: '',
enabled: true,
),
]);
await transport.loadExternalAcpCapabilities(
target: AssistantExecutionTarget.singleAgent,
);
expect(bridge.methods, <String>[
'xworkmate.providers.sync',
'xworkmate.providers.sync',
'acp.capabilities',
]);
});
test('syncs providers before session start requests', () async {
final bridge = _FakeGoAcpStdioBridgeWithSyncOrder();
final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge);
await transport
.syncExternalProviders(const <ExternalCodeAgentAcpSyncedProvider>[
ExternalCodeAgentAcpSyncedProvider(
providerId: 'codex',
label: 'Codex',
endpoint: 'https://acp-server.svc.plus/codex/acp/rpc',
authorizationHeader: '',
enabled: true,
),
]);
await transport.executeTask(
const GoTaskServiceRequest(
sessionId: 's1',
threadId: 't1',
target: AssistantExecutionTarget.singleAgent,
prompt: 'hello',
workingDirectory: '/tmp',
model: '',
thinking: '',
selectedSkills: <String>[],
inlineAttachments: <GatewayChatAttachmentPayload>[],
localAttachments: <CollaborationAttachment>[],
aiGatewayBaseUrl: '',
aiGatewayApiKey: '',
agentId: '',
metadata: <String, dynamic>{},
),
onUpdate: (_) {},
);
expect(bridge.methods, <String>[
'xworkmate.providers.sync',
'xworkmate.providers.sync',
'session.start',
]);
});
});
}