diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 2f80449a..8253feac 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -20,6 +20,7 @@ import '../runtime/runtime_controllers.dart'; import '../runtime/runtime_models.dart'; import '../runtime/secure_config_store.dart'; import '../runtime/runtime_coordinator.dart'; +import '../runtime/direct_single_agent_app_server_client.dart'; import '../runtime/gateway_acp_client.dart'; import '../runtime/codex_runtime.dart'; import '../runtime/codex_config_bridge.dart'; @@ -93,14 +94,18 @@ class AppController extends ChangeNotifier { (_isFlutterTestEnvironment ? const [] : _defaultGatewayOnlySkillScanRoots); - _gatewayAcpClient = GatewayAcpClient(endpointResolver: _resolveAcpEndpoint); + _gatewayAcpClient = + GatewayAcpClient(endpointResolver: _resolveGatewayAcpEndpoint); + _singleAgentAppServerClient = DirectSingleAgentAppServerClient( + endpointResolver: _resolveSingleAgentEndpoint, + ); _availableSingleAgentProvidersOverride = availableSingleAgentProvidersOverride; _arisBundleRepository = ArisBundleRepository(); _arisBridgeLocator = ArisBridgeLocator(); _singleAgentRunner = singleAgentRunner ?? - DefaultSingleAgentRunner(acpClient: _gatewayAcpClient); + DefaultSingleAgentRunner(appServerClient: _singleAgentAppServerClient); _multiAgentOrchestrator = MultiAgentOrchestrator( config: _resolveMultiAgentConfig(_settingsController.snapshot), arisBundleRepository: _arisBundleRepository, @@ -132,13 +137,14 @@ class AppController extends ChangeNotifier { late final DesktopPlatformService _desktopPlatformService; late final List _gatewayOnlySkillScanRoots; late final GatewayAcpClient _gatewayAcpClient; + late final DirectSingleAgentAppServerClient _singleAgentAppServerClient; late final List? _availableSingleAgentProvidersOverride; late final ArisBundleRepository _arisBundleRepository; late final ArisBridgeLocator _arisBridgeLocator; late final SingleAgentRunner _singleAgentRunner; late final MultiAgentOrchestrator _multiAgentOrchestrator; - GatewayAcpCapabilities _acpCapabilities = - const GatewayAcpCapabilities.empty(); + DirectSingleAgentCapabilities _singleAgentCapabilities = + const DirectSingleAgentCapabilities.unavailable(endpoint: ''); final Map> _assistantThreadMessages = >{}; final Map _assistantThreadRecords = @@ -320,7 +326,8 @@ class AppController extends ChangeNotifier { resolvedAiGatewayModel.isNotEmpty; List get availableSingleAgentProviders => - SingleAgentProvider.values + (_availableSingleAgentProvidersOverride ?? + const [SingleAgentProvider.codex]) .where((item) => item != SingleAgentProvider.auto) .where(_canUseSingleAgentProvider) .toList(growable: false); @@ -329,20 +336,17 @@ class AppController extends ChangeNotifier { availableSingleAgentProviders.isNotEmpty; bool _canUseSingleAgentProvider(SingleAgentProvider provider) { - if (!allowsAppStoreExternalSingleAgentProviders( - isAppleHost: Platform.isIOS || Platform.isMacOS, - )) { - return false; - } final override = _availableSingleAgentProvidersOverride; if (override != null) { return provider != SingleAgentProvider.auto && override.contains(provider); } if (provider == SingleAgentProvider.auto) { - return _acpCapabilities.providers.isNotEmpty; + return hasAnyAvailableSingleAgentProvider; } - return _acpCapabilities.providers.contains(provider); + return provider == SingleAgentProvider.codex && + _singleAgentCapabilities.available && + _singleAgentCapabilities.supportsCodex; } SingleAgentProvider? _resolvedSingleAgentProvider( @@ -563,11 +567,10 @@ class AppController extends ChangeNotifier { singleAgentModelDisplayLabelForSession(currentSessionKey); List get singleAgentProviderOptions => - allowsAppStoreExternalSingleAgentProviders( - isAppleHost: Platform.isIOS || Platform.isMacOS, - ) - ? SingleAgentProvider.values - : const [SingleAgentProvider.auto]; + const [ + SingleAgentProvider.auto, + SingleAgentProvider.codex, + ]; String singleAgentProviderLabelForSession(String sessionKey) { return singleAgentProviderForSession(sessionKey).label; @@ -2490,6 +2493,16 @@ class AppController extends ChangeNotifier { /// Enable Codex ↔ Gateway bridge Future enableCodexBridge() async { if (_isCodexBridgeEnabled || _isCodexBridgeBusy) return; + if (blocksAppStoreEmbeddedAgentProcesses( + isAppleHost: Platform.isIOS || Platform.isMacOS, + )) { + throw StateError( + appText( + 'App Store 版本不允许在应用内启动或桥接外部 CLI 进程。', + 'App Store builds do not allow in-app external CLI bridge processes.', + ), + ); + } _isCodexBridgeBusy = true; _codexBridgeError = null; @@ -2505,13 +2518,14 @@ class AppController extends ChangeNotifier { } await _refreshAcpCapabilities(forceRefresh: true); + await _refreshSingleAgentCapabilities(forceRefresh: true); final runtimeMode = effectiveCodeAgentRuntimeMode; if (runtimeMode == CodeAgentRuntimeMode.externalCli && !_canUseSingleAgentProvider(SingleAgentProvider.codex)) { throw StateError( appText( - 'Gateway ACP 未报告 Codex Provider 可用,请先检查 Agent Gateway / ACP Adapter 配置。', - 'Gateway ACP did not report a Codex provider. Check Agent Gateway / ACP Adapter settings first.', + '外部 single-agent endpoint 未报告 Codex 可用,请先检查 app-server / Gateway 配置。', + 'The external single-agent endpoint did not report Codex availability. Check the app-server or Gateway endpoint first.', ), ); } @@ -2585,6 +2599,7 @@ class AppController extends ChangeNotifier { _store.dispose(); _desktopPlatformService.dispose(); unawaited(_gatewayAcpClient.dispose()); + unawaited(_singleAgentAppServerClient.dispose()); super.dispose(); } @@ -2630,6 +2645,7 @@ class AppController extends ChangeNotifier { await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin); await _refreshResolvedCodexCliPath(); _registerCodexExternalProvider(); + await _refreshSingleAgentCapabilities(); await _refreshAcpCapabilities(persistMountTargets: true); if (_disposed) { return; @@ -2817,6 +2833,7 @@ class AppController extends ChangeNotifier { await _refreshResolvedCodexCliPath(); _registerCodexExternalProvider(); } + unawaited(_refreshSingleAgentCapabilities()); if (previous.linuxDesktop.toJson().toString() != current.linuxDesktop.toJson().toString() || previous.launchAtLogin != current.launchAtLogin) { @@ -3078,9 +3095,11 @@ class AppController extends ChangeNotifier { try { final selection = singleAgentProviderForSession(sessionKey); + final gatewayToken = await settingsController.loadGatewayToken(); final resolution = await _singleAgentRunner.resolveProvider( selection: selection, configuredCodexCliPath: configuredCodexCliPath, + gatewayToken: gatewayToken, ); final provider = resolution.resolvedProvider; if (provider == null) { @@ -3154,6 +3173,7 @@ class AppController extends ChangeNotifier { provider: provider, prompt: message, model: assistantModelForSession(sessionKey), + gatewayToken: gatewayToken, workingDirectory: _resolveCodexWorkingDirectory() ?? Directory.current.path, attachments: localAttachments, @@ -4418,7 +4438,6 @@ class AppController extends ChangeNotifier { } catch (_) { capabilities = const GatewayAcpCapabilities.empty(); } - _acpCapabilities = capabilities; if (persistMountTargets && !_disposed) { final currentConfig = settings.multiAgent; final nextTargets = _mergeAcpCapabilitiesIntoMountTargets( @@ -4437,11 +4456,35 @@ class AppController extends ChangeNotifier { _notifyIfActive(); } + Future _refreshSingleAgentCapabilities({ + bool forceRefresh = false, + }) async { + try { + _singleAgentCapabilities = await _singleAgentAppServerClient + .loadCapabilities( + forceRefresh: forceRefresh, + gatewayToken: await settingsController.loadGatewayToken(), + ); + } catch (_) { + _singleAgentCapabilities = + const DirectSingleAgentCapabilities.unavailable(endpoint: ''); + } + if (!_disposed) { + _notifyIfActive(); + } + } + Future _refreshResolvedCodexCliPath() async { if (effectiveCodeAgentRuntimeMode != CodeAgentRuntimeMode.externalCli) { _resolvedCodexCliPath = null; return; } + if (blocksAppStoreEmbeddedAgentProcesses( + isAppleHost: Platform.isIOS || Platform.isMacOS, + )) { + _resolvedCodexCliPath = null; + return; + } final configuredPath = configuredCodexCliPath; String? detectedPath; @@ -4510,7 +4553,7 @@ class AppController extends ChangeNotifier { } void _registerCodexExternalProvider() { - final endpoint = _resolveAcpEndpoint()?.replace( + final endpoint = _resolveGatewayAcpEndpoint()?.replace( path: '/acp', query: null, fragment: null, @@ -4690,14 +4733,20 @@ class AppController extends ChangeNotifier { notifyListeners(); } - Uri? _resolveAcpEndpoint() { + Uri? _resolveSingleAgentEndpoint() { + final remote = _gatewayProfileBaseUri(settings.primaryRemoteGatewayProfile); + if (remote != null) { + return remote; + } + return _gatewayProfileBaseUri(settings.primaryLocalGatewayProfile); + } + + Uri? _resolveGatewayAcpEndpoint() { final target = assistantExecutionTargetForSession( _sessionsController.currentSessionKey, ); if (target == AssistantExecutionTarget.singleAgent) { - final remote = _gatewayProfileBaseUri( - settings.primaryRemoteGatewayProfile, - ); + final remote = _gatewayProfileBaseUri(settings.primaryRemoteGatewayProfile); if (remote != null) { return remote; } diff --git a/lib/app/app_store_policy.dart b/lib/app/app_store_policy.dart index 109e051c..b22bf486 100644 --- a/lib/app/app_store_policy.dart +++ b/lib/app/app_store_policy.dart @@ -123,11 +123,11 @@ UiFeatureManifest applyAppleAppStorePolicy( return next; } -bool allowsAppStoreExternalSingleAgentProviders({ +bool blocksAppStoreEmbeddedAgentProcesses({ required bool isAppleHost, bool? enabled, }) { - return !shouldApplyAppleAppStorePolicy( + return shouldApplyAppleAppStorePolicy( isAppleHost: isAppleHost, enabled: enabled, ); @@ -138,10 +138,12 @@ SingleAgentProvider sanitizeAppStoreSingleAgentProvider( required bool isAppleHost, bool? enabled, }) { - if (!allowsAppStoreExternalSingleAgentProviders( - isAppleHost: isAppleHost, - enabled: enabled, - )) { + if (blocksAppStoreEmbeddedAgentProcesses( + isAppleHost: isAppleHost, + enabled: enabled, + ) && + provider != SingleAgentProvider.auto && + provider != SingleAgentProvider.codex) { return SingleAgentProvider.auto; } return provider; diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 5b9338a2..96e484d5 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -1056,8 +1056,8 @@ class _SettingsPageState extends State { children: [ Text( appText( - '这里仅维护 OpenClaw 连接源 profile。工作模式在会话区单独切换;保存:仅保存配置,不立即生效。应用:立即按当前配置生效。', - 'This card edits OpenClaw connection source profiles only. Work mode is switched in the session UI. Save persists configuration only, while Apply makes it take effect immediately.', + '这里维护外部 Gateway / app-server 连接源 profile。工作模式在会话区单独切换:single-agent 直连外部 WS app-server;local/remote 继续走 Gateway。保存:仅保存配置,不立即生效。应用:立即按当前配置生效。', + 'This card edits external Gateway and app-server endpoint profiles. Work mode is switched in the session UI: single-agent connects to an external WS app-server directly, while local/remote continue through Gateway. Save persists configuration only, while Apply makes it take effect immediately.', ), style: theme.textTheme.bodyMedium, ), diff --git a/lib/runtime/aris_llm_chat_client.dart b/lib/runtime/aris_llm_chat_client.dart index a0820a36..dde2e835 100644 --- a/lib/runtime/aris_llm_chat_client.dart +++ b/lib/runtime/aris_llm_chat_client.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import '../app/app_store_policy.dart'; import 'aris_bridge.dart'; typedef ArisProcessStarter = @@ -87,6 +88,13 @@ class ArisLlmChatClient { required Map environment, required Map arguments, }) async { + if (blocksAppStoreEmbeddedAgentProcesses( + isAppleHost: Platform.isIOS || Platform.isMacOS, + )) { + throw UnsupportedError( + 'App Store builds do not allow launching the bundled ARIS bridge process.', + ); + } final launch = await _bridgeLocator.locate(); if (launch == null) { throw StateError('ARIS Go bridge is unavailable.'); diff --git a/lib/runtime/codex_runtime.dart b/lib/runtime/codex_runtime.dart index 36a7db01..25e55c8a 100644 --- a/lib/runtime/codex_runtime.dart +++ b/lib/runtime/codex_runtime.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import '../app/app_store_policy.dart'; import '../app/app_metadata.dart'; import 'platform_environment.dart'; @@ -353,6 +354,13 @@ class CodexRuntime extends ChangeNotifier { CodexApprovalPolicy approval = CodexApprovalPolicy.suggest, List extraArgs = const [], }) async { + if (blocksAppStoreEmbeddedAgentProcesses( + isAppleHost: Platform.isIOS || Platform.isMacOS, + )) { + throw UnsupportedError( + 'App Store builds do not allow launching a local Codex app-server process.', + ); + } if (_process != null) { throw StateError('Codex already running'); } diff --git a/lib/runtime/direct_single_agent_app_server_client.dart b/lib/runtime/direct_single_agent_app_server_client.dart new file mode 100644 index 00000000..d0c17816 --- /dev/null +++ b/lib/runtime/direct_single_agent_app_server_client.dart @@ -0,0 +1,556 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +class DirectSingleAgentCapabilities { + const DirectSingleAgentCapabilities({ + required this.available, + required this.supportsCodex, + required this.endpoint, + this.errorMessage, + }); + + const DirectSingleAgentCapabilities.unavailable({ + required this.endpoint, + this.errorMessage, + }) : available = false, + supportsCodex = false; + + final bool available; + final bool supportsCodex; + final String endpoint; + final String? errorMessage; +} + +class DirectSingleAgentRunResult { + const DirectSingleAgentRunResult({ + required this.success, + required this.output, + required this.errorMessage, + this.aborted = false, + }); + + final bool success; + final String output; + final String errorMessage; + final bool aborted; +} + +class DirectSingleAgentRunRequest { + const DirectSingleAgentRunRequest({ + required this.sessionId, + required this.prompt, + required this.model, + required this.workingDirectory, + required this.gatewayToken, + this.onOutput, + }); + + final String sessionId; + final String prompt; + final String model; + final String workingDirectory; + final String gatewayToken; + final void Function(String text)? onOutput; +} + +class DirectSingleAgentAppServerClient { + DirectSingleAgentAppServerClient({required this.endpointResolver}); + + final Uri? Function() endpointResolver; + + final Map _activeConnections = + {}; + final Map _threadIds = {}; + final Set _abortedSessions = {}; + + DirectSingleAgentCapabilities _cachedCapabilities = + const DirectSingleAgentCapabilities.unavailable(endpoint: ''); + DateTime? _capabilitiesRefreshedAt; + + Future loadCapabilities({ + bool forceRefresh = false, + String gatewayToken = '', + }) async { + if (!forceRefresh && + _capabilitiesRefreshedAt != null && + DateTime.now().difference(_capabilitiesRefreshedAt!) < + const Duration(seconds: 15)) { + return _cachedCapabilities; + } + + final endpoint = _resolveWebSocketEndpoint(); + if (endpoint == null) { + _cachedCapabilities = const DirectSingleAgentCapabilities.unavailable( + endpoint: '', + errorMessage: 'Single-agent app-server endpoint is not configured.', + ); + _capabilitiesRefreshedAt = DateTime.now(); + return _cachedCapabilities; + } + + _DirectAppServerConnection? connection; + try { + connection = await _DirectAppServerConnection.connect( + endpoint, + gatewayToken: gatewayToken, + ); + await connection.initialize(); + _cachedCapabilities = DirectSingleAgentCapabilities( + available: true, + supportsCodex: true, + endpoint: endpoint.toString(), + ); + } catch (error) { + _cachedCapabilities = DirectSingleAgentCapabilities.unavailable( + endpoint: endpoint.toString(), + errorMessage: error.toString(), + ); + } finally { + _capabilitiesRefreshedAt = DateTime.now(); + await connection?.close(); + } + + return _cachedCapabilities; + } + + Future run( + DirectSingleAgentRunRequest request, + ) async { + final endpoint = _resolveWebSocketEndpoint(); + if (endpoint == null) { + return const DirectSingleAgentRunResult( + success: false, + output: '', + errorMessage: 'Single-agent app-server endpoint is missing.', + ); + } + + final normalizedSessionId = request.sessionId.trim(); + if (normalizedSessionId.isEmpty) { + return const DirectSingleAgentRunResult( + success: false, + output: '', + errorMessage: 'Single-agent session id is missing.', + ); + } + + _abortedSessions.remove(normalizedSessionId); + final connection = await _DirectAppServerConnection.connect( + endpoint, + gatewayToken: request.gatewayToken, + ); + _activeConnections[normalizedSessionId] = connection; + + try { + await connection.initialize(); + final threadId = await _ensureThread( + connection, + sessionId: normalizedSessionId, + workingDirectory: request.workingDirectory, + model: request.model, + ); + + final output = StringBuffer(); + final completion = Completer(); + late final StreamSubscription> subscription; + subscription = connection.notifications.listen( + (notification) { + final method = notification['method']?.toString().trim() ?? ''; + final params = _asMap(notification['params']); + if (params['threadId']?.toString() != threadId) { + return; + } + if (method == 'item/agentMessage/delta') { + final delta = params['delta']?.toString() ?? ''; + if (delta.isNotEmpty) { + output.write(delta); + request.onOutput?.call(delta); + } + return; + } + if (method == 'turn/completed' && !completion.isCompleted) { + completion.complete( + DirectSingleAgentRunResult( + success: true, + output: output.toString(), + errorMessage: '', + ), + ); + return; + } + if ((method == 'turn/failed' || method == 'turn/error') && + !completion.isCompleted) { + final aborted = + _abortedSessions.contains(normalizedSessionId) || + (params['message']?.toString().toLowerCase().contains( + 'abort', + ) ?? + false); + completion.complete( + DirectSingleAgentRunResult( + success: false, + output: output.toString(), + aborted: aborted, + errorMessage: + params['message']?.toString() ?? + params['error']?.toString() ?? + 'Single-agent app-server turn failed.', + ), + ); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!completion.isCompleted) { + completion.complete( + DirectSingleAgentRunResult( + success: false, + output: output.toString(), + errorMessage: error.toString(), + aborted: _abortedSessions.contains(normalizedSessionId), + ), + ); + } + }, + onDone: () { + if (!completion.isCompleted) { + completion.complete( + DirectSingleAgentRunResult( + success: false, + output: output.toString(), + errorMessage: _abortedSessions.contains(normalizedSessionId) + ? 'Single-agent app-server run aborted.' + : 'Single-agent app-server connection closed before completion.', + aborted: _abortedSessions.contains(normalizedSessionId), + ), + ); + } + }, + ); + + try { + await connection.request( + 'turn/start', + params: { + 'threadId': threadId, + 'userInput': { + 'type': 'message', + 'content': request.prompt, + }, + }, + ); + return await completion.future.timeout( + const Duration(minutes: 10), + onTimeout: () => DirectSingleAgentRunResult( + success: false, + output: output.toString(), + errorMessage: 'Single-agent app-server request timed out.', + aborted: _abortedSessions.contains(normalizedSessionId), + ), + ); + } finally { + await subscription.cancel(); + } + } catch (error) { + return DirectSingleAgentRunResult( + success: false, + output: '', + errorMessage: error.toString(), + aborted: _abortedSessions.contains(normalizedSessionId), + ); + } finally { + _activeConnections.remove(normalizedSessionId); + await connection.close(); + _abortedSessions.remove(normalizedSessionId); + } + } + + Future abort(String sessionId) async { + final normalizedSessionId = sessionId.trim(); + if (normalizedSessionId.isEmpty) { + return; + } + _abortedSessions.add(normalizedSessionId); + final connection = _activeConnections[normalizedSessionId]; + final threadId = _threadIds[normalizedSessionId]; + if (connection == null || threadId == null || threadId.isEmpty) { + return; + } + try { + await connection.request( + 'turn/interrupt', + params: {'threadId': threadId}, + ); + } catch (_) { + // Best effort only. + } + await connection.close(); + } + + Future dispose() async { + final connections = _activeConnections.values.toList(growable: false); + _activeConnections.clear(); + for (final connection in connections) { + await connection.close(); + } + } + + Future _ensureThread( + _DirectAppServerConnection connection, { + required String sessionId, + required String workingDirectory, + required String model, + }) async { + final existingThreadId = _threadIds[sessionId]?.trim() ?? ''; + if (existingThreadId.isNotEmpty) { + try { + final resumed = await connection.request( + 'thread/resume', + params: { + 'threadId': existingThreadId, + if (workingDirectory.trim().isNotEmpty) 'cwd': workingDirectory, + }, + ); + final resumedId = resumed['id']?.toString().trim() ?? existingThreadId; + _threadIds[sessionId] = resumedId; + return resumedId; + } catch (_) { + _threadIds.remove(sessionId); + } + } + + final created = await connection.request( + 'thread/start', + params: { + if (workingDirectory.trim().isNotEmpty) 'cwd': workingDirectory, + if (model.trim().isNotEmpty) 'model': model.trim(), + }, + ); + final threadId = created['id']?.toString().trim() ?? ''; + if (threadId.isEmpty) { + throw StateError('Single-agent app-server returned an empty thread id.'); + } + _threadIds[sessionId] = threadId; + return threadId; + } + + Uri? _resolveWebSocketEndpoint() { + final base = endpointResolver(); + if (base == null) { + return null; + } + final scheme = base.scheme.toLowerCase(); + if (scheme == 'ws' || scheme == 'wss') { + return base.replace(path: '', query: null, fragment: null); + } + if (scheme == 'http' || scheme == 'https') { + return base.replace( + scheme: scheme == 'https' ? 'wss' : 'ws', + path: '', + query: null, + fragment: null, + ); + } + return null; + } +} + +class _DirectAppServerConnection { + _DirectAppServerConnection(this._socket); + + final WebSocket _socket; + final StreamController> _notifications = + StreamController>.broadcast(); + final Map>> _pendingRequests = + >>{}; + int _requestCounter = 0; + bool _initialized = false; + StreamSubscription? _subscription; + + Stream> get notifications => _notifications.stream; + + static Future<_DirectAppServerConnection> connect( + Uri endpoint, { + String gatewayToken = '', + }) async { + final headers = {}; + final normalizedToken = gatewayToken.trim(); + if (normalizedToken.isNotEmpty) { + headers[HttpHeaders.authorizationHeader] = 'Bearer $normalizedToken'; + } + final socket = await WebSocket.connect( + endpoint.toString(), + headers: headers.isEmpty ? null : headers, + ).timeout( + const Duration(seconds: 8), + onTimeout: () => throw TimeoutException( + 'Single-agent app-server websocket connect timed out.', + ), + ); + final connection = _DirectAppServerConnection(socket); + connection._attach(); + return connection; + } + + Future initialize() async { + if (_initialized) { + return; + } + await request( + 'initialize', + params: const { + 'clientInfo': { + 'name': 'xworkmate', + 'version': '0', + }, + 'capabilities': { + 'optOutNotificationMethods': [], + }, + }, + ); + await notify('initialized', params: const {}); + _initialized = true; + } + + Future> request( + String method, { + Map params = const {}, + Duration timeout = const Duration(seconds: 60), + }) async { + final id = '${DateTime.now().microsecondsSinceEpoch}-${_requestCounter++}'; + final completer = Completer>(); + _pendingRequests[id] = completer; + _socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'method': method, + 'params': params, + }), + ); + return completer.future.timeout( + timeout, + onTimeout: () { + _pendingRequests.remove(id); + throw TimeoutException('Single-agent app-server request $method timed out.'); + }, + ); + } + + Future notify( + String method, { + required Map params, + }) async { + _socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'method': method, + 'params': params, + }), + ); + } + + void _attach() { + _subscription = _socket.listen( + (dynamic raw) { + final message = _decodeMap(raw); + final id = message['id']?.toString(); + if (id != null && message.containsKey('result')) { + final completer = _pendingRequests.remove(id); + if (completer != null && !completer.isCompleted) { + completer.complete(_asMap(message['result'])); + } + return; + } + if (id != null && message.containsKey('error')) { + final completer = _pendingRequests.remove(id); + if (completer != null && !completer.isCompleted) { + final error = _asMap(message['error']); + completer.completeError( + StateError( + error['message']?.toString() ?? + 'Single-agent app-server request failed.', + ), + ); + } + return; + } + if (message.containsKey('method')) { + _notifications.add(message); + } + }, + onError: (Object error, StackTrace stackTrace) { + for (final completer in _pendingRequests.values) { + if (!completer.isCompleted) { + completer.completeError(error); + } + } + _pendingRequests.clear(); + _notifications.addError(error, stackTrace); + }, + onDone: () { + final error = StateError( + 'Single-agent app-server websocket closed unexpectedly.', + ); + for (final completer in _pendingRequests.values) { + if (!completer.isCompleted) { + completer.completeError(error); + } + } + _pendingRequests.clear(); + if (!_notifications.isClosed) { + unawaited(_notifications.close()); + } + }, + cancelOnError: true, + ); + } + + Future close() async { + await _subscription?.cancel(); + _subscription = null; + for (final completer in _pendingRequests.values) { + if (!completer.isCompleted) { + completer.completeError( + StateError('Single-agent app-server connection closed.'), + ); + } + } + _pendingRequests.clear(); + if (!_notifications.isClosed) { + await _notifications.close(); + } + try { + await _socket.close(); + } catch (_) { + // Best effort only. + } + } +} + +Map _decodeMap(Object raw) { + if (raw is Map) { + return raw; + } + if (raw is Map) { + return raw.cast(); + } + final decoded = jsonDecode(raw.toString()); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return decoded.cast(); + } + return const {}; +} + +Map _asMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return const {}; +} diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index bb1232a8..67ff2946 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -36,8 +36,8 @@ class GatewayAcpCapabilities { final Map raw; } -class GatewayAcpSessionUpdate { - const GatewayAcpSessionUpdate({ +class _GatewayAcpSessionUpdate { + const _GatewayAcpSessionUpdate({ required this.method, required this.sessionId, required this.threadId, @@ -58,50 +58,6 @@ class GatewayAcpSessionUpdate { final Map payload; } -class GatewayAcpSingleAgentRequest { - const GatewayAcpSingleAgentRequest({ - required this.sessionId, - required this.threadId, - required this.provider, - required this.prompt, - required this.model, - required this.workingDirectory, - required this.attachments, - required this.selectedSkills, - required this.aiGatewayBaseUrl, - required this.aiGatewayApiKey, - required this.resumeSession, - }); - - final String sessionId; - final String threadId; - final SingleAgentProvider provider; - final String prompt; - final String model; - final String workingDirectory; - final List attachments; - final List selectedSkills; - final String aiGatewayBaseUrl; - final String aiGatewayApiKey; - final bool resumeSession; -} - -class GatewayAcpSingleAgentResult { - const GatewayAcpSingleAgentResult({ - required this.success, - required this.output, - required this.errorMessage, - required this.turnId, - required this.raw, - }); - - final bool success; - final String output; - final String errorMessage; - final String turnId; - final Map raw; -} - class GatewayAcpMultiAgentRequest { const GatewayAcpMultiAgentRequest({ required this.sessionId, @@ -189,82 +145,6 @@ class GatewayAcpClient { return _cachedCapabilities; } - Future runSingleAgent( - GatewayAcpSingleAgentRequest request, { - void Function(GatewayAcpSessionUpdate update)? onUpdate, - }) async { - final capabilities = await loadCapabilities(); - if (!capabilities.singleAgent || - !capabilities.providers.contains(request.provider)) { - throw GatewayAcpException( - 'Single-agent provider ${request.provider.providerId} is unavailable from ACP capabilities', - code: 'ACP_SINGLE_AGENT_UNAVAILABLE', - ); - } - final outputBuffer = StringBuffer(); - var lastSequence = -1; - final rpcRequest = _GatewayAcpRpcRequest( - id: _nextRequestId('single-agent'), - method: request.resumeSession ? 'session.message' : 'session.start', - params: { - 'sessionId': request.sessionId, - 'threadId': request.threadId, - 'mode': 'single-agent', - 'provider': request.provider.providerId, - 'taskPrompt': request.prompt, - 'model': request.model, - 'workingDirectory': request.workingDirectory, - 'attachments': request.attachments - .map( - (item) => { - 'name': item.name, - 'description': item.description, - 'path': item.path, - }, - ) - .toList(growable: false), - 'selectedSkills': request.selectedSkills, - 'aiGatewayBaseUrl': request.aiGatewayBaseUrl, - 'aiGatewayApiKey': request.aiGatewayApiKey, - }, - ); - final response = await _requestWithFallback( - rpcRequest, - onNotification: (notification) { - final update = _sessionUpdateFromNotification(notification); - if (update == null) { - return; - } - if (update.sessionId != request.sessionId) { - return; - } - if (update.sequence != null && update.sequence! <= lastSequence) { - return; - } - if (update.sequence != null) { - lastSequence = update.sequence!; - } - if (update.textDelta.isNotEmpty) { - outputBuffer.write(update.textDelta); - } - onUpdate?.call(update); - }, - ); - final result = asMap(response['result']); - final explicitOutput = _extractOutput(result); - final output = explicitOutput.isNotEmpty - ? explicitOutput - : outputBuffer.toString().trim(); - final success = boolValue(result['success']) ?? output.isNotEmpty; - return GatewayAcpSingleAgentResult( - success: success, - output: output, - errorMessage: stringValue(result['error']) ?? '', - turnId: stringValue(result['turnId']) ?? '', - raw: result, - ); - } - Stream runMultiAgent( GatewayAcpMultiAgentRequest request, ) { @@ -589,7 +469,7 @@ class GatewayAcpClient { return resolved; } - GatewayAcpSessionUpdate? _sessionUpdateFromNotification( + _GatewayAcpSessionUpdate? _sessionUpdateFromNotification( Map notification, ) { final method = stringValue(notification['method']) ?? ''; @@ -597,7 +477,7 @@ class GatewayAcpClient { return null; } final params = asMap(notification['params']); - return GatewayAcpSessionUpdate( + return _GatewayAcpSessionUpdate( method: method, sessionId: stringValue(params['sessionId']) ?? '', threadId: stringValue(params['threadId']) ?? '', @@ -642,27 +522,6 @@ class GatewayAcpClient { ); } - String _extractOutput(Map result) { - final direct = stringValue(result['output']); - if ((direct ?? '').trim().isNotEmpty) { - return direct!.trim(); - } - final text = stringValue(result['text']); - if ((text ?? '').trim().isNotEmpty) { - return text!.trim(); - } - final summary = stringValue(result['summary']); - if ((summary ?? '').trim().isNotEmpty) { - return summary!.trim(); - } - final message = asMap(result['message']); - final messageContent = stringValue(message['content']); - if ((messageContent ?? '').trim().isNotEmpty) { - return messageContent!.trim(); - } - return ''; - } - Map asMap(Object? raw) { if (raw is Map) { return raw; diff --git a/lib/runtime/multi_agent_orchestrator.dart b/lib/runtime/multi_agent_orchestrator.dart index e277de6a..472435b9 100644 --- a/lib/runtime/multi_agent_orchestrator.dart +++ b/lib/runtime/multi_agent_orchestrator.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import '../app/app_store_policy.dart'; import 'aris_bundle.dart'; import 'aris_bridge.dart'; import 'aris_llm_chat_client.dart'; @@ -123,6 +124,16 @@ class MultiAgentOrchestrator extends ChangeNotifier { } } + void _assertEmbeddedProcessesAllowed() { + if (blocksAppStoreEmbeddedAgentProcesses( + isAppleHost: Platform.isIOS || Platform.isMacOS, + )) { + throw UnsupportedError( + 'App Store builds do not allow launching embedded multi-agent subprocesses.', + ); + } + } + /// 启用协作模式 void enable() { _config = _config.copyWith(enabled: true); @@ -159,6 +170,7 @@ class MultiAgentOrchestrator extends ChangeNotifier { String aiGatewayApiKey = '', void Function(MultiAgentRunEvent event)? onEvent, }) async { + _assertEmbeddedProcessesAllowed(); if (_isRunning) { throw StateError('Collaboration is already running'); } diff --git a/lib/runtime/single_agent_runner.dart b/lib/runtime/single_agent_runner.dart index 88df18e5..87540054 100644 --- a/lib/runtime/single_agent_runner.dart +++ b/lib/runtime/single_agent_runner.dart @@ -1,4 +1,4 @@ -import 'gateway_acp_client.dart'; +import 'direct_single_agent_app_server_client.dart'; import 'multi_agent_orchestrator.dart'; import 'runtime_models.dart'; @@ -21,6 +21,7 @@ class SingleAgentRunRequest { required this.prompt, required this.model, required this.workingDirectory, + required this.gatewayToken, required this.attachments, required this.selectedSkills, required this.aiGatewayBaseUrl, @@ -35,6 +36,7 @@ class SingleAgentRunRequest { final String prompt; final String model; final String workingDirectory; + final String gatewayToken; final List attachments; final List selectedSkills; final String aiGatewayBaseUrl; @@ -68,6 +70,7 @@ abstract class SingleAgentRunner { Future resolveProvider({ required SingleAgentProvider selection, required String configuredCodexCliPath, + required String gatewayToken, }); Future run(SingleAgentRunRequest request); @@ -76,62 +79,50 @@ abstract class SingleAgentRunner { } class DefaultSingleAgentRunner implements SingleAgentRunner { - DefaultSingleAgentRunner({required GatewayAcpClient acpClient}) - : _acpClient = acpClient; + DefaultSingleAgentRunner({ + required DirectSingleAgentAppServerClient appServerClient, + }) : _appServerClient = appServerClient; - static const List _autoOrder = [ - SingleAgentProvider.codex, - SingleAgentProvider.opencode, - SingleAgentProvider.claude, - SingleAgentProvider.gemini, - ]; - - final GatewayAcpClient _acpClient; + final DirectSingleAgentAppServerClient _appServerClient; @override Future resolveProvider({ required SingleAgentProvider selection, required String configuredCodexCliPath, + required String gatewayToken, }) async { try { - final capabilities = await _acpClient.loadCapabilities(); - if (!capabilities.singleAgent) { + final capabilities = await _appServerClient.loadCapabilities( + gatewayToken: gatewayToken, + ); + if (!capabilities.available || !capabilities.supportsCodex) { return SingleAgentProviderResolution( selection: selection, resolvedProvider: null, - fallbackReason: 'ACP single-agent capability is unavailable.', + fallbackReason: + capabilities.errorMessage ?? + 'Single-agent app-server is unavailable.', ); } - if (selection != SingleAgentProvider.auto) { - final available = capabilities.providers.contains(selection); + if (selection != SingleAgentProvider.auto && + selection != SingleAgentProvider.codex) { return SingleAgentProviderResolution( selection: selection, - resolvedProvider: available ? selection : null, - fallbackReason: available - ? null - : '${selection.label} provider is unavailable from ACP adapter.', + resolvedProvider: null, + fallbackReason: + '${selection.label} is unavailable from the direct app-server endpoint.', ); } - - for (final provider in _autoOrder) { - if (capabilities.providers.contains(provider)) { - return SingleAgentProviderResolution( - selection: selection, - resolvedProvider: provider, - fallbackReason: null, - ); - } - } - return const SingleAgentProviderResolution( - selection: SingleAgentProvider.auto, - resolvedProvider: null, - fallbackReason: 'No ACP single-agent provider is currently available.', + return SingleAgentProviderResolution( + selection: selection, + resolvedProvider: SingleAgentProvider.codex, + fallbackReason: null, ); } catch (error) { return SingleAgentProviderResolution( selection: selection, resolvedProvider: null, - fallbackReason: 'ACP capability negotiation failed: $error', + fallbackReason: 'Single-agent app-server negotiation failed: $error', ); } } @@ -139,25 +130,15 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { @override Future run(SingleAgentRunRequest request) async { try { - final result = await _acpClient.runSingleAgent( - GatewayAcpSingleAgentRequest( + final result = await _appServerClient.run( + DirectSingleAgentRunRequest( sessionId: request.sessionId, - threadId: request.sessionId, - provider: request.provider, prompt: _augmentPrompt(request), model: request.model, workingDirectory: request.workingDirectory, - attachments: request.attachments, - selectedSkills: request.selectedSkills, - aiGatewayBaseUrl: request.aiGatewayBaseUrl, - aiGatewayApiKey: request.aiGatewayApiKey, - resumeSession: true, + gatewayToken: request.gatewayToken, + onOutput: request.onOutput, ), - onUpdate: (update) { - if (update.textDelta.isNotEmpty) { - request.onOutput?.call(update.textDelta); - } - }, ); return SingleAgentRunResult( provider: request.provider, @@ -165,12 +146,13 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { success: result.success, errorMessage: result.errorMessage, shouldFallbackToAiChat: !result.success && result.output.isEmpty, + aborted: result.aborted, fallbackReason: !result.success - ? 'ACP single-agent run failed: ${result.errorMessage}' + ? 'Single-agent app-server run failed: ${result.errorMessage}' : null, ); - } on GatewayAcpException catch (error) { - final shouldFallback = _shouldFallbackToAiChat(error.code, error.message); + } catch (error) { + final shouldFallback = _shouldFallbackToAiChat(error.toString()); return SingleAgentRunResult( provider: request.provider, output: '', @@ -178,19 +160,9 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { errorMessage: error.toString(), shouldFallbackToAiChat: shouldFallback, fallbackReason: shouldFallback - ? '${request.provider.label} provider is unavailable from ACP adapter.' + ? '${request.provider.label} provider is unavailable from the direct app-server endpoint.' : null, ); - } catch (error) { - return SingleAgentRunResult( - provider: request.provider, - output: '', - success: false, - errorMessage: error.toString(), - shouldFallbackToAiChat: true, - fallbackReason: - '${request.provider.label} provider run failed before completion.', - ); } } @@ -200,29 +172,16 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { if (normalized.isEmpty) { return; } - try { - await _acpClient.cancelSession( - sessionId: normalized, - threadId: normalized, - ); - } catch (_) { - // Best effort only. - } + await _appServerClient.abort(normalized); } - bool _shouldFallbackToAiChat(String? code, String message) { - final normalizedCode = code?.trim().toUpperCase() ?? ''; - if (normalizedCode == 'ACP_ENDPOINT_MISSING' || - normalizedCode == 'ACP_HTTP_ENDPOINT_MISSING' || - normalizedCode == 'ACP_WS_CONNECT_TIMEOUT' || - normalizedCode == 'ACP_WS_RUNTIME_ERROR' || - normalizedCode == 'ACP_WS_EARLY_CLOSE') { - return true; - } + bool _shouldFallbackToAiChat(String message) { final normalizedMessage = message.toLowerCase(); return normalizedMessage.contains('timeout') || normalizedMessage.contains('unavailable') || - normalizedMessage.contains('missing'); + normalizedMessage.contains('missing') || + normalizedMessage.contains('closed') || + normalizedMessage.contains('connect'); } String _augmentPrompt(SingleAgentRunRequest request) { diff --git a/test/app/app_store_policy_test.dart b/test/app/app_store_policy_test.dart index 2735a7a7..8dc9fdbe 100644 --- a/test/app/app_store_policy_test.dart +++ b/test/app/app_store_policy_test.dart @@ -50,13 +50,23 @@ void main() { ); }); - test('single-agent provider selection is forced to auto for app store', () { + test( + 'app store policy keeps external codex but strips embedded-only providers', + () { expect( sanitizeAppStoreSingleAgentProvider( SingleAgentProvider.codex, isAppleHost: true, enabled: true, ), + SingleAgentProvider.codex, + ); + expect( + sanitizeAppStoreSingleAgentProvider( + SingleAgentProvider.gemini, + isAppleHost: true, + enabled: true, + ), SingleAgentProvider.auto, ); expect( @@ -67,5 +77,23 @@ void main() { ), SingleAgentProvider.gemini, ); + }, + ); + + test('apple app store policy blocks embedded agent processes', () { + expect( + blocksAppStoreEmbeddedAgentProcesses( + isAppleHost: true, + enabled: true, + ), + isTrue, + ); + expect( + blocksAppStoreEmbeddedAgentProcesses( + isAppleHost: false, + enabled: true, + ), + isFalse, + ); }); } diff --git a/test/runtime/app_controller_ai_gateway_chat_suite.dart b/test/runtime/app_controller_ai_gateway_chat_suite.dart index 94976dae..fb45ac1b 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite.dart @@ -657,6 +657,7 @@ class _FakeSingleAgentRunner implements SingleAgentRunner { Future resolveProvider({ required SingleAgentProvider selection, required String configuredCodexCliPath, + required String gatewayToken, }) async { resolveCalls += 1; return SingleAgentProviderResolution( diff --git a/test/runtime/direct_single_agent_app_server_suite.dart b/test/runtime/direct_single_agent_app_server_suite.dart new file mode 100644 index 00000000..220c5556 --- /dev/null +++ b/test/runtime/direct_single_agent_app_server_suite.dart @@ -0,0 +1,297 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/direct_single_agent_app_server_client.dart'; + +void main() { + group('DirectSingleAgentAppServerClient', () { + test('probes websocket endpoint and reports codex support', () async { + final server = await _FakeAppServer.start(); + addTearDown(server.close); + + final client = DirectSingleAgentAppServerClient( + endpointResolver: () => server.baseHttpUri, + ); + + final capabilities = await client.loadCapabilities(); + + expect(capabilities.available, isTrue); + expect(capabilities.supportsCodex, isTrue); + expect(capabilities.endpoint, 'ws://127.0.0.1:${server.port}'); + expect(server.methods, contains('initialize')); + }); + + test('runs single-agent turns over direct websocket app-server', () async { + final server = await _FakeAppServer.start(); + addTearDown(server.close); + + final client = DirectSingleAgentAppServerClient( + endpointResolver: () => server.baseHttpUri, + ); + addTearDown(client.dispose); + + final deltas = []; + final result = await client.run( + const DirectSingleAgentRunRequest( + sessionId: 'session-1', + prompt: 'hello world', + model: 'gpt-4.1', + workingDirectory: '/tmp', + gatewayToken: 'token-1', + ).copyWith(onOutput: deltas.add), + ); + + expect(result.success, isTrue); + expect(result.output, 'hello world from app server'); + expect(deltas.join(), 'hello world from app server'); + expect(server.methods, containsAll([ + 'initialize', + 'thread/start', + 'turn/start', + ])); + expect(server.authorizationHeaders, contains('Bearer token-1')); + }); + + test('interrupts active turns on abort', () async { + final server = await _FakeAppServer.start(delayCompletion: true); + addTearDown(server.close); + + final client = DirectSingleAgentAppServerClient( + endpointResolver: () => server.baseHttpUri, + ); + addTearDown(client.dispose); + + final runFuture = client.run( + const DirectSingleAgentRunRequest( + sessionId: 'session-abort', + prompt: 'abort me', + model: 'gpt-4.1', + workingDirectory: '/tmp', + gatewayToken: '', + ), + ); + + await server.waitForMethod('turn/start'); + await client.abort('session-abort'); + final result = await runFuture; + + expect(result.aborted, isTrue); + expect(server.methods, contains('turn/interrupt')); + }); + }); +} + +class _FakeAppServer { + _FakeAppServer._(this._server, {required this.delayCompletion}); + + final HttpServer _server; + final bool delayCompletion; + final List methods = []; + final List authorizationHeaders = []; + final Map> _methodWaiters = >{}; + int _threadCounter = 0; + + int get port => _server.port; + Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${_server.port}'); + + static Future<_FakeAppServer> start({bool delayCompletion = false}) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = _FakeAppServer._(server, delayCompletion: delayCompletion); + unawaited(fake._listen()); + return fake; + } + + Future close() async { + await _server.close(force: true); + } + + Future waitForMethod(String method) async { + if (methods.contains(method)) { + return; + } + final completer = _methodWaiters.putIfAbsent(method, Completer.new); + await completer.future.timeout(const Duration(seconds: 3)); + } + + Future _listen() async { + await for (final request in _server) { + authorizationHeaders.add( + request.headers.value(HttpHeaders.authorizationHeader) ?? '', + ); + if (request.uri.path == '/' && WebSocketTransformer.isUpgradeRequest(request)) { + final socket = await WebSocketTransformer.upgrade(request); + unawaited(_handleSocket(socket)); + continue; + } + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } + } + + Future _handleSocket(WebSocket socket) async { + await for (final raw in socket) { + final message = _decodeMap(raw); + final method = message['method']?.toString() ?? ''; + final id = message['id']; + final params = _asMap(message['params']); + if (method.isEmpty) { + continue; + } + methods.add(method); + _methodWaiters.remove(method)?.complete(); + switch (method) { + case 'initialize': + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': { + 'serverInfo': {'name': 'fake-codex'}, + }, + })); + break; + case 'initialized': + break; + case 'thread/start': + _threadCounter += 1; + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': { + 'id': 'thread-$_threadCounter', + 'path': params['cwd'] ?? '/tmp', + 'ephemeral': false, + }, + })); + break; + case 'thread/resume': + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': { + 'id': params['threadId'] ?? 'thread-resumed', + 'path': params['cwd'] ?? '/tmp', + 'ephemeral': false, + }, + })); + break; + case 'turn/start': + final threadId = params['threadId']?.toString() ?? 'thread-1'; + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': { + 'id': 'turn-1', + 'threadId': threadId, + 'status': 'started', + }, + })); + unawaited(_emitTurn(socket, threadId)); + break; + case 'turn/interrupt': + final threadId = params['threadId']?.toString() ?? 'thread-1'; + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': {'ok': true}, + })); + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'method': 'turn/error', + 'params': { + 'threadId': threadId, + 'message': 'aborted', + }, + })); + await socket.close(); + break; + default: + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'error': { + 'code': -32601, + 'message': 'unknown method $method', + }, + })); + } + } + } + + Future _emitTurn(WebSocket socket, String threadId) async { + const parts = ['hello ', 'world ', 'from app server']; + for (final part in parts) { + try { + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'method': 'item/agentMessage/delta', + 'params': { + 'threadId': threadId, + 'turnId': 'turn-1', + 'delta': part, + }, + })); + } catch (_) { + return; + } + await Future.delayed(const Duration(milliseconds: 5)); + } + if (delayCompletion) { + return; + } + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'method': 'turn/completed', + 'params': { + 'threadId': threadId, + 'turnId': 'turn-1', + }, + })); + } +} + +Map _decodeMap(Object raw) { + if (raw is Map) { + return raw; + } + if (raw is Map) { + return raw.cast(); + } + final decoded = jsonDecode(raw.toString()); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return decoded.cast(); + } + return const {}; +} + +Map _asMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return const {}; +} + +extension on DirectSingleAgentRunRequest { + DirectSingleAgentRunRequest copyWith({ + void Function(String text)? onOutput, + }) { + return DirectSingleAgentRunRequest( + sessionId: sessionId, + prompt: prompt, + model: model, + workingDirectory: workingDirectory, + gatewayToken: gatewayToken, + onOutput: onOutput ?? this.onOutput, + ); + } +} diff --git a/test/runtime/direct_single_agent_app_server_test.dart b/test/runtime/direct_single_agent_app_server_test.dart new file mode 100644 index 00000000..672872a8 --- /dev/null +++ b/test/runtime/direct_single_agent_app_server_test.dart @@ -0,0 +1,7 @@ +import '../test_suite_stub.dart' + if (dart.library.io) 'direct_single_agent_app_server_suite.dart' + as suite; + +void main() { + suite.main(); +} diff --git a/test/runtime/gateway_acp_client_suite.dart b/test/runtime/gateway_acp_client_suite.dart index 0f9999c6..89302327 100644 --- a/test/runtime/gateway_acp_client_suite.dart +++ b/test/runtime/gateway_acp_client_suite.dart @@ -12,43 +12,21 @@ import 'package:xworkmate/runtime/runtime_models.dart'; void main() { group('GatewayAcpClient', () { - test( - 'prefers websocket for single-agent run and streams updates', - () async { - final server = await _AcpFakeServer.start(); - addTearDown(server.close); + test('loads ACP capabilities over websocket when available', () async { + final server = await _AcpFakeServer.start(); + addTearDown(server.close); - final client = GatewayAcpClient( - endpointResolver: () => server.baseHttpUri, - ); + final client = GatewayAcpClient( + endpointResolver: () => server.baseHttpUri, + ); - final updates = []; - final result = await client.runSingleAgent( - GatewayAcpSingleAgentRequest( - sessionId: 'session-ws', - threadId: 'thread-ws', - provider: SingleAgentProvider.codex, - prompt: 'hello ws', - model: 'gpt-4.1', - workingDirectory: '/tmp', - attachments: const [], - selectedSkills: const ['review'], - aiGatewayBaseUrl: 'https://example.invalid', - aiGatewayApiKey: 'test-key', - resumeSession: false, - ), - onUpdate: updates.add, - ); + final capabilities = await client.loadCapabilities(forceRefresh: true); - expect(result.success, isTrue); - expect(result.output, 'single-agent result (codex)'); - expect(result.turnId, 'turn-single'); - expect(updates, isNotEmpty); - expect(updates.first.textDelta, 'delta-single'); - expect(server.rpcMethods, contains('acp.capabilities')); - expect(server.rpcMethods, contains('session.start')); - }, - ); + expect(capabilities.singleAgent, isTrue); + expect(capabilities.multiAgent, isTrue); + expect(capabilities.providers, contains(SingleAgentProvider.codex)); + expect(server.rpcMethods, contains('acp.capabilities')); + }); test('falls back to HTTP+SSE when websocket is unavailable', () async { final server = await _AcpFakeServer.start(disableWebSocket: true); @@ -58,29 +36,12 @@ void main() { endpointResolver: () => server.baseHttpUri, ); - final updates = []; - final result = await client.runSingleAgent( - GatewayAcpSingleAgentRequest( - sessionId: 'session-sse', - threadId: 'thread-sse', - provider: SingleAgentProvider.claude, - prompt: 'hello sse', - model: 'claude-sonnet', - workingDirectory: '/tmp', - attachments: const [], - selectedSkills: const [], - aiGatewayBaseUrl: 'https://example.invalid', - aiGatewayApiKey: 'test-key', - resumeSession: false, - ), - onUpdate: updates.add, - ); + final capabilities = await client.loadCapabilities(forceRefresh: true); - expect(result.success, isTrue); - expect(result.output, 'single-agent result (claude)'); - expect(updates.map((item) => item.textDelta), contains('delta-single')); + expect(capabilities.singleAgent, isTrue); + expect(capabilities.multiAgent, isTrue); + expect(capabilities.providers, contains(SingleAgentProvider.claude)); expect(server.rpcMethods, contains('acp.capabilities')); - expect(server.rpcMethods, contains('session.start')); }); test(