refactor(appstore): use external single-agent app-server
This commit is contained in:
parent
47273a2d41
commit
ee09c13d88
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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-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,
|
||||
),
|
||||
|
||||
@ -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.');
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
556
lib/runtime/direct_single_agent_app_server_client.dart
Normal file
556
lib/runtime/direct_single_agent_app_server_client.dart
Normal 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>{};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
297
test/runtime/direct_single_agent_app_server_suite.dart
Normal file
297
test/runtime/direct_single_agent_app_server_suite.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
7
test/runtime/direct_single_agent_app_server_test.dart
Normal file
7
test/runtime/direct_single_agent_app_server_test.dart
Normal 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();
|
||||
}
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user