merge: external single-agent app-server for app store

This commit is contained in:
Haitao Pan 2026-03-23 18:20:49 +08:00
commit 5f2e42e9ff
14 changed files with 1062 additions and 315 deletions

View File

@ -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 <String>[]
: _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<String> _gatewayOnlySkillScanRoots;
late final GatewayAcpClient _gatewayAcpClient;
late final DirectSingleAgentAppServerClient _singleAgentAppServerClient;
late final List<SingleAgentProvider>? _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<String, List<GatewayChatMessage>> _assistantThreadMessages =
<String, List<GatewayChatMessage>>{};
final Map<String, AssistantThreadRecord> _assistantThreadRecords =
@ -320,7 +326,8 @@ class AppController extends ChangeNotifier {
resolvedAiGatewayModel.isNotEmpty;
List<SingleAgentProvider> get availableSingleAgentProviders =>
SingleAgentProvider.values
(_availableSingleAgentProvidersOverride ??
const <SingleAgentProvider>[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<SingleAgentProvider> get singleAgentProviderOptions =>
allowsAppStoreExternalSingleAgentProviders(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)
? SingleAgentProvider.values
: const <SingleAgentProvider>[SingleAgentProvider.auto];
const <SingleAgentProvider>[
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<void> 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<void> _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<void> _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;
}

View File

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

View File

@ -1056,8 +1056,8 @@ class _SettingsPageState extends State<SettingsPage> {
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-serverlocal/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,
),

View File

@ -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<String, String> environment,
required Map<String, dynamic> 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.');

View File

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

View File

@ -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<String, _DirectAppServerConnection> _activeConnections =
<String, _DirectAppServerConnection>{};
final Map<String, String> _threadIds = <String, String>{};
final Set<String> _abortedSessions = <String>{};
DirectSingleAgentCapabilities _cachedCapabilities =
const DirectSingleAgentCapabilities.unavailable(endpoint: '');
DateTime? _capabilitiesRefreshedAt;
Future<DirectSingleAgentCapabilities> 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<DirectSingleAgentRunResult> 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<DirectSingleAgentRunResult>();
late final StreamSubscription<Map<String, dynamic>> 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: <String, dynamic>{
'threadId': threadId,
'userInput': <String, dynamic>{
'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<void> 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: <String, dynamic>{'threadId': threadId},
);
} catch (_) {
// Best effort only.
}
await connection.close();
}
Future<void> dispose() async {
final connections = _activeConnections.values.toList(growable: false);
_activeConnections.clear();
for (final connection in connections) {
await connection.close();
}
}
Future<String> _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: <String, dynamic>{
'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: <String, dynamic>{
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<Map<String, dynamic>> _notifications =
StreamController<Map<String, dynamic>>.broadcast();
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests =
<String, Completer<Map<String, dynamic>>>{};
int _requestCounter = 0;
bool _initialized = false;
StreamSubscription<dynamic>? _subscription;
Stream<Map<String, dynamic>> get notifications => _notifications.stream;
static Future<_DirectAppServerConnection> connect(
Uri endpoint, {
String gatewayToken = '',
}) async {
final headers = <String, dynamic>{};
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<void> initialize() async {
if (_initialized) {
return;
}
await request(
'initialize',
params: const <String, dynamic>{
'clientInfo': <String, dynamic>{
'name': 'xworkmate',
'version': '0',
},
'capabilities': <String, dynamic>{
'optOutNotificationMethods': <String>[],
},
},
);
await notify('initialized', params: const <String, dynamic>{});
_initialized = true;
}
Future<Map<String, dynamic>> request(
String method, {
Map<String, dynamic> params = const <String, dynamic>{},
Duration timeout = const Duration(seconds: 60),
}) async {
final id = '${DateTime.now().microsecondsSinceEpoch}-${_requestCounter++}';
final completer = Completer<Map<String, dynamic>>();
_pendingRequests[id] = completer;
_socket.add(
jsonEncode(<String, dynamic>{
'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<void> notify(
String method, {
required Map<String, dynamic> params,
}) async {
_socket.add(
jsonEncode(<String, dynamic>{
'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<void> 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<String, dynamic> _decodeMap(Object raw) {
if (raw is Map<String, dynamic>) {
return raw;
}
if (raw is Map) {
return raw.cast<String, dynamic>();
}
final decoded = jsonDecode(raw.toString());
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return decoded.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
Map<String, dynamic> _asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.cast<String, dynamic>();
}
return const <String, dynamic>{};
}

View File

@ -36,8 +36,8 @@ class GatewayAcpCapabilities {
final Map<String, dynamic> 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<String, dynamic> 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<CollaborationAttachment> attachments;
final List<String> 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<String, dynamic> raw;
}
class GatewayAcpMultiAgentRequest {
const GatewayAcpMultiAgentRequest({
required this.sessionId,
@ -189,82 +145,6 @@ class GatewayAcpClient {
return _cachedCapabilities;
}
Future<GatewayAcpSingleAgentResult> 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: <String, dynamic>{
'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) => <String, dynamic>{
'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<MultiAgentRunEvent> runMultiAgent(
GatewayAcpMultiAgentRequest request,
) {
@ -589,7 +469,7 @@ class GatewayAcpClient {
return resolved;
}
GatewayAcpSessionUpdate? _sessionUpdateFromNotification(
_GatewayAcpSessionUpdate? _sessionUpdateFromNotification(
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> asMap(Object? raw) {
if (raw is Map<String, dynamic>) {
return raw;

View File

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

View File

@ -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<CollaborationAttachment> attachments;
final List<String> selectedSkills;
final String aiGatewayBaseUrl;
@ -68,6 +70,7 @@ abstract class SingleAgentRunner {
Future<SingleAgentProviderResolution> resolveProvider({
required SingleAgentProvider selection,
required String configuredCodexCliPath,
required String gatewayToken,
});
Future<SingleAgentRunResult> 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<SingleAgentProvider> _autoOrder = <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.claude,
SingleAgentProvider.gemini,
];
final GatewayAcpClient _acpClient;
final DirectSingleAgentAppServerClient _appServerClient;
@override
Future<SingleAgentProviderResolution> 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<SingleAgentRunResult> 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) {

View File

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

View File

@ -657,6 +657,7 @@ class _FakeSingleAgentRunner implements SingleAgentRunner {
Future<SingleAgentProviderResolution> resolveProvider({
required SingleAgentProvider selection,
required String configuredCodexCliPath,
required String gatewayToken,
}) async {
resolveCalls += 1;
return SingleAgentProviderResolution(

View File

@ -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 = <String>[];
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(<String>[
'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<String> methods = <String>[];
final List<String> authorizationHeaders = <String>[];
final Map<String, Completer<void>> _methodWaiters = <String, Completer<void>>{};
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<void> close() async {
await _server.close(force: true);
}
Future<void> waitForMethod(String method) async {
if (methods.contains(method)) {
return;
}
final completer = _methodWaiters.putIfAbsent(method, Completer<void>.new);
await completer.future.timeout(const Duration(seconds: 3));
}
Future<void> _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<void> _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(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'serverInfo': <String, dynamic>{'name': 'fake-codex'},
},
}));
break;
case 'initialized':
break;
case 'thread/start':
_threadCounter += 1;
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'id': 'thread-$_threadCounter',
'path': params['cwd'] ?? '/tmp',
'ephemeral': false,
},
}));
break;
case 'thread/resume':
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'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(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'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(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{'ok': true},
}));
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'turn/error',
'params': <String, dynamic>{
'threadId': threadId,
'message': 'aborted',
},
}));
await socket.close();
break;
default:
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'error': <String, dynamic>{
'code': -32601,
'message': 'unknown method $method',
},
}));
}
}
}
Future<void> _emitTurn(WebSocket socket, String threadId) async {
const parts = <String>['hello ', 'world ', 'from app server'];
for (final part in parts) {
try {
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'item/agentMessage/delta',
'params': <String, dynamic>{
'threadId': threadId,
'turnId': 'turn-1',
'delta': part,
},
}));
} catch (_) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 5));
}
if (delayCompletion) {
return;
}
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'turn/completed',
'params': <String, dynamic>{
'threadId': threadId,
'turnId': 'turn-1',
},
}));
}
}
Map<String, dynamic> _decodeMap(Object raw) {
if (raw is Map<String, dynamic>) {
return raw;
}
if (raw is Map) {
return raw.cast<String, dynamic>();
}
final decoded = jsonDecode(raw.toString());
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return decoded.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
Map<String, dynamic> _asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
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,
);
}
}

View File

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

View File

@ -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 = <GatewayAcpSessionUpdate>[];
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 <CollaborationAttachment>[],
selectedSkills: const <String>['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 = <GatewayAcpSessionUpdate>[];
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 <CollaborationAttachment>[],
selectedSkills: const <String>[],
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(