Add ACP endpoint settings tab
This commit is contained in:
parent
42dc9e6e3c
commit
8d960a7899
@ -192,8 +192,9 @@ class AppController extends ChangeNotifier {
|
||||
late final GoCoreLocator _goCoreLocator;
|
||||
late final SingleAgentRunner _singleAgentRunner;
|
||||
late final MultiAgentOrchestrator _multiAgentOrchestrator;
|
||||
DirectSingleAgentCapabilities _singleAgentCapabilities =
|
||||
const DirectSingleAgentCapabilities.unavailable(endpoint: '');
|
||||
Map<SingleAgentProvider, DirectSingleAgentCapabilities>
|
||||
_singleAgentCapabilitiesByProvider =
|
||||
const <SingleAgentProvider, DirectSingleAgentCapabilities>{};
|
||||
final Map<String, List<GatewayChatMessage>> _assistantThreadMessages =
|
||||
<String, List<GatewayChatMessage>>{};
|
||||
final Map<String, AssistantThreadRecord> _assistantThreadRecords =
|
||||
@ -392,8 +393,7 @@ class AppController extends ChangeNotifier {
|
||||
_settingsController.storedGatewayPasswordMaskForProfile(profileIndex);
|
||||
|
||||
List<SingleAgentProvider> get availableSingleAgentProviders =>
|
||||
(_availableSingleAgentProvidersOverride ??
|
||||
const <SingleAgentProvider>[SingleAgentProvider.codex])
|
||||
(_availableSingleAgentProvidersOverride ?? kBuiltinExternalAcpProviders)
|
||||
.where((item) => item != SingleAgentProvider.auto)
|
||||
.where(_canUseSingleAgentProvider)
|
||||
.toList(growable: false);
|
||||
@ -410,9 +410,9 @@ class AppController extends ChangeNotifier {
|
||||
if (provider == SingleAgentProvider.auto) {
|
||||
return hasAnyAvailableSingleAgentProvider;
|
||||
}
|
||||
return provider == SingleAgentProvider.codex &&
|
||||
_singleAgentCapabilities.available &&
|
||||
_singleAgentCapabilities.supportsCodex;
|
||||
final capabilities = _singleAgentCapabilitiesByProvider[provider];
|
||||
return capabilities?.available == true &&
|
||||
capabilities!.supportsProvider(provider);
|
||||
}
|
||||
|
||||
SingleAgentProvider? _resolvedSingleAgentProvider(
|
||||
@ -627,7 +627,7 @@ class AppController extends ChangeNotifier {
|
||||
List<SingleAgentProvider> get singleAgentProviderOptions =>
|
||||
const <SingleAgentProvider>[
|
||||
SingleAgentProvider.auto,
|
||||
SingleAgentProvider.codex,
|
||||
...kBuiltinExternalAcpProviders,
|
||||
];
|
||||
|
||||
String singleAgentProviderLabelForSession(String sessionKey) {
|
||||
@ -4504,16 +4504,29 @@ class AppController extends ChangeNotifier {
|
||||
Future<void> _refreshSingleAgentCapabilities({
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
try {
|
||||
_singleAgentCapabilities = await _singleAgentAppServerClient
|
||||
.loadCapabilities(
|
||||
forceRefresh: forceRefresh,
|
||||
gatewayToken: await settingsController.loadGatewayToken(),
|
||||
);
|
||||
} catch (_) {
|
||||
_singleAgentCapabilities =
|
||||
const DirectSingleAgentCapabilities.unavailable(endpoint: '');
|
||||
final gatewayToken = await settingsController.loadGatewayToken();
|
||||
final next = <SingleAgentProvider, DirectSingleAgentCapabilities>{};
|
||||
for (final provider in kBuiltinExternalAcpProviders) {
|
||||
final profile = settings.externalAcpEndpointForProvider(provider);
|
||||
if (!profile.enabled || profile.endpoint.trim().isEmpty) {
|
||||
next[provider] = const DirectSingleAgentCapabilities.unavailable(
|
||||
endpoint: '',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
next[provider] = await _singleAgentAppServerClient.loadCapabilities(
|
||||
provider: provider,
|
||||
forceRefresh: forceRefresh,
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
} catch (_) {
|
||||
next[provider] = const DirectSingleAgentCapabilities.unavailable(
|
||||
endpoint: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
_singleAgentCapabilitiesByProvider = next;
|
||||
if (!_disposed) {
|
||||
_notifyIfActive();
|
||||
}
|
||||
@ -4598,11 +4611,7 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _registerCodexExternalProvider() {
|
||||
final endpoint = _resolveGatewayAcpEndpoint()?.replace(
|
||||
path: '/acp',
|
||||
query: null,
|
||||
fragment: null,
|
||||
);
|
||||
final endpoint = _resolveSingleAgentEndpoint(SingleAgentProvider.codex);
|
||||
_runtimeCoordinator.registerExternalCodeAgent(
|
||||
ExternalCodeAgentProvider(
|
||||
id: 'codex',
|
||||
@ -4778,12 +4787,29 @@ class AppController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Uri? _resolveSingleAgentEndpoint() {
|
||||
final remote = _gatewayProfileBaseUri(settings.primaryRemoteGatewayProfile);
|
||||
if (remote != null) {
|
||||
return remote;
|
||||
Uri? _resolveSingleAgentEndpoint(SingleAgentProvider provider) {
|
||||
final endpoint = settings
|
||||
.externalAcpEndpointForProvider(provider)
|
||||
.endpoint
|
||||
.trim();
|
||||
if (endpoint.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _gatewayProfileBaseUri(settings.primaryLocalGatewayProfile);
|
||||
final normalizedInput = endpoint.contains('://')
|
||||
? endpoint
|
||||
: 'ws://$endpoint';
|
||||
final uri = Uri.tryParse(normalizedInput);
|
||||
if (uri == null || uri.host.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final scheme = uri.scheme.trim().toLowerCase();
|
||||
if (scheme != 'ws' &&
|
||||
scheme != 'wss' &&
|
||||
scheme != 'http' &&
|
||||
scheme != 'https') {
|
||||
return null;
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
Uri? _resolveGatewayAcpEndpoint() {
|
||||
|
||||
@ -71,6 +71,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
String _aiGatewayTestState = 'idle';
|
||||
String _aiGatewayTestMessage = '';
|
||||
String _aiGatewayTestEndpoint = '';
|
||||
_GatewayIntegrationSubTab _integrationSubTab =
|
||||
_GatewayIntegrationSubTab.gateway;
|
||||
int _llmEndpointSlotLimit = 1;
|
||||
int _selectedLlmEndpointIndex = 0;
|
||||
String _aiGatewayNameSyncedValue = '';
|
||||
@ -790,41 +792,171 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
SettingsSnapshot settings,
|
||||
UiFeatureAccess uiFeatures,
|
||||
) {
|
||||
final tabLabel = switch (_integrationSubTab) {
|
||||
_GatewayIntegrationSubTab.gateway => 'OpenClaw Gateway',
|
||||
_GatewayIntegrationSubTab.llm => appText('LLM 接入点', 'LLM Endpoints'),
|
||||
_GatewayIntegrationSubTab.acp => appText('ACP 外部接入', 'External ACP'),
|
||||
};
|
||||
return [
|
||||
_buildCollapsibleGatewaySection(
|
||||
context: context,
|
||||
title: 'OpenClaw Gateway',
|
||||
expanded: _openClawGatewayExpanded,
|
||||
SectionTabs(
|
||||
items: <String>[
|
||||
'OpenClaw Gateway',
|
||||
appText('LLM 接入点', 'LLM Endpoints'),
|
||||
appText('ACP 外部接入', 'External ACP'),
|
||||
],
|
||||
value: tabLabel,
|
||||
onChanged: (value) => setState(() {
|
||||
_openClawGatewayExpanded = value;
|
||||
_integrationSubTab = switch (value) {
|
||||
'OpenClaw Gateway' => _GatewayIntegrationSubTab.gateway,
|
||||
_ when value == appText('LLM 接入点', 'LLM Endpoints') =>
|
||||
_GatewayIntegrationSubTab.llm,
|
||||
_ => _GatewayIntegrationSubTab.acp,
|
||||
};
|
||||
}),
|
||||
child: _buildOpenClawGatewayCard(context, controller, settings),
|
||||
),
|
||||
if (uiFeatures.supportsVaultServer) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildCollapsibleGatewaySection(
|
||||
context: context,
|
||||
title: appText('Vault Server', 'Vault Server'),
|
||||
expanded: _vaultServerExpanded,
|
||||
onChanged: (value) => setState(() {
|
||||
_vaultServerExpanded = value;
|
||||
}),
|
||||
child: _buildVaultProviderCard(context, controller, settings),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_buildCollapsibleGatewaySection(
|
||||
context: context,
|
||||
title: appText('LLM 接入点', 'LLM Endpoints'),
|
||||
expanded: _aiGatewayExpanded,
|
||||
onChanged: (value) => setState(() {
|
||||
_aiGatewayExpanded = value;
|
||||
}),
|
||||
child: _buildLlmEndpointManager(context, controller, settings),
|
||||
),
|
||||
...switch (_integrationSubTab) {
|
||||
_GatewayIntegrationSubTab.gateway => <Widget>[
|
||||
_buildCollapsibleGatewaySection(
|
||||
context: context,
|
||||
title: 'OpenClaw Gateway',
|
||||
expanded: _openClawGatewayExpanded,
|
||||
onChanged: (value) => setState(() {
|
||||
_openClawGatewayExpanded = value;
|
||||
}),
|
||||
child: _buildOpenClawGatewayCard(context, controller, settings),
|
||||
),
|
||||
if (uiFeatures.supportsVaultServer) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildCollapsibleGatewaySection(
|
||||
context: context,
|
||||
title: appText('Vault Server', 'Vault Server'),
|
||||
expanded: _vaultServerExpanded,
|
||||
onChanged: (value) => setState(() {
|
||||
_vaultServerExpanded = value;
|
||||
}),
|
||||
child: _buildVaultProviderCard(context, controller, settings),
|
||||
),
|
||||
],
|
||||
],
|
||||
_GatewayIntegrationSubTab.llm => <Widget>[
|
||||
_buildCollapsibleGatewaySection(
|
||||
context: context,
|
||||
title: appText('LLM 接入点', 'LLM Endpoints'),
|
||||
expanded: _aiGatewayExpanded,
|
||||
onChanged: (value) => setState(() {
|
||||
_aiGatewayExpanded = value;
|
||||
}),
|
||||
child: _buildLlmEndpointManager(context, controller, settings),
|
||||
),
|
||||
],
|
||||
_GatewayIntegrationSubTab.acp => <Widget>[
|
||||
_buildExternalAcpEndpointManager(context, controller, settings),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildExternalAcpEndpointManager(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
SettingsSnapshot settings,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
return SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('外部 ACP Server Endpoint', 'External ACP Server Endpoints'),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText(
|
||||
'第一批内置 4 个 provider:Codex、OpenCode、Claude、Gemini。每个 provider 都可以自定义接入自己的 ACP Server Endpoint,协议支持 ws / wss / http / https。Gateway profile 与 ACP endpoint 分开存储,后续可在这个列表上扩展自定义 provider。',
|
||||
'The first batch includes 4 built-in providers: Codex, OpenCode, Claude, and Gemini. Each provider can point to its own ACP server endpoint with ws / wss / http / https. Gateway profiles and ACP endpoints are stored separately, and this list is designed to extend to custom providers later.',
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...kBuiltinExternalAcpProviders.map(
|
||||
(provider) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildExternalAcpProviderCard(
|
||||
context,
|
||||
controller,
|
||||
settings,
|
||||
provider,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExternalAcpProviderCard(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
SettingsSnapshot settings,
|
||||
SingleAgentProvider provider,
|
||||
) {
|
||||
final profile = settings.externalAcpEndpointForProvider(provider);
|
||||
final endpoint = profile.endpoint.trim();
|
||||
final configured = endpoint.isNotEmpty;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
provider.label,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
_StatusChip(
|
||||
label: configured
|
||||
? appText('已配置', 'Configured')
|
||||
: appText('未配置', 'Empty'),
|
||||
tone: configured ? _StatusChipTone.ready : _StatusChipTone.idle,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_EditableField(
|
||||
label: appText(
|
||||
'${provider.label} ACP Endpoint',
|
||||
'${provider.label} ACP Endpoint',
|
||||
),
|
||||
value: endpoint,
|
||||
onSubmitted: (value) => _saveSettings(
|
||||
controller,
|
||||
settings.copyWithExternalAcpEndpointForProvider(
|
||||
provider,
|
||||
profile.copyWith(endpoint: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
appText(
|
||||
'示例:ws://127.0.0.1:9001、wss://acp.example.com/rpc、http://127.0.0.1:8080、https://agent.example.com',
|
||||
'Examples: ws://127.0.0.1:9001, wss://acp.example.com/rpc, http://127.0.0.1:8080, https://agent.example.com',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLlmEndpointManager(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
@ -4591,6 +4723,8 @@ class _WorkflowStep extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum _GatewayIntegrationSubTab { gateway, llm, acp }
|
||||
|
||||
enum _LlmEndpointSlot { aiGateway, ollamaLocal, ollamaCloud }
|
||||
|
||||
const List<_LlmEndpointSlot> _llmEndpointSlots = <_LlmEndpointSlot>[
|
||||
@ -4598,3 +4732,40 @@ const List<_LlmEndpointSlot> _llmEndpointSlots = <_LlmEndpointSlot>[
|
||||
_LlmEndpointSlot.ollamaLocal,
|
||||
_LlmEndpointSlot.ollamaCloud,
|
||||
];
|
||||
|
||||
enum _StatusChipTone { idle, ready }
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.label, required this.tone});
|
||||
|
||||
final String label;
|
||||
final _StatusChipTone tone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final (background, foreground) = switch (tone) {
|
||||
_StatusChipTone.ready => (
|
||||
colorScheme.primaryContainer,
|
||||
colorScheme.onPrimaryContainer,
|
||||
),
|
||||
_StatusChipTone.idle => (
|
||||
colorScheme.surfaceContainerHighest,
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelMedium?.copyWith(color: foreground),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,12 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'runtime_models.dart';
|
||||
|
||||
class DirectSingleAgentCapabilities {
|
||||
const DirectSingleAgentCapabilities({
|
||||
required this.available,
|
||||
required this.supportsCodex,
|
||||
required this.supportedProviders,
|
||||
required this.endpoint,
|
||||
this.errorMessage,
|
||||
});
|
||||
@ -14,12 +16,17 @@ class DirectSingleAgentCapabilities {
|
||||
required this.endpoint,
|
||||
this.errorMessage,
|
||||
}) : available = false,
|
||||
supportsCodex = false;
|
||||
supportedProviders = const <SingleAgentProvider>[];
|
||||
|
||||
final bool available;
|
||||
final bool supportsCodex;
|
||||
final List<SingleAgentProvider> supportedProviders;
|
||||
final String endpoint;
|
||||
final String? errorMessage;
|
||||
|
||||
bool get supportsCodex => supportsProvider(SingleAgentProvider.codex);
|
||||
|
||||
bool supportsProvider(SingleAgentProvider provider) =>
|
||||
supportedProviders.contains(provider);
|
||||
}
|
||||
|
||||
class DirectSingleAgentRunResult {
|
||||
@ -39,6 +46,7 @@ class DirectSingleAgentRunResult {
|
||||
class DirectSingleAgentRunRequest {
|
||||
const DirectSingleAgentRunRequest({
|
||||
required this.sessionId,
|
||||
required this.provider,
|
||||
required this.prompt,
|
||||
required this.model,
|
||||
required this.workingDirectory,
|
||||
@ -47,6 +55,7 @@ class DirectSingleAgentRunRequest {
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final SingleAgentProvider provider;
|
||||
final String prompt;
|
||||
final String model;
|
||||
final String workingDirectory;
|
||||
@ -57,36 +66,41 @@ class DirectSingleAgentRunRequest {
|
||||
class DirectSingleAgentAppServerClient {
|
||||
DirectSingleAgentAppServerClient({required this.endpointResolver});
|
||||
|
||||
final Uri? Function() endpointResolver;
|
||||
final Uri? Function(SingleAgentProvider provider) 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;
|
||||
final Map<SingleAgentProvider, DirectSingleAgentCapabilities>
|
||||
_cachedCapabilities = <SingleAgentProvider, DirectSingleAgentCapabilities>{};
|
||||
final Map<SingleAgentProvider, DateTime> _capabilitiesRefreshedAt =
|
||||
<SingleAgentProvider, DateTime>{};
|
||||
|
||||
Future<DirectSingleAgentCapabilities> loadCapabilities({
|
||||
required SingleAgentProvider provider,
|
||||
bool forceRefresh = false,
|
||||
String gatewayToken = '',
|
||||
}) async {
|
||||
final cached = _cachedCapabilities[provider];
|
||||
final refreshedAt = _capabilitiesRefreshedAt[provider];
|
||||
if (!forceRefresh &&
|
||||
_capabilitiesRefreshedAt != null &&
|
||||
DateTime.now().difference(_capabilitiesRefreshedAt!) <
|
||||
const Duration(seconds: 15)) {
|
||||
return _cachedCapabilities;
|
||||
cached != null &&
|
||||
refreshedAt != null &&
|
||||
DateTime.now().difference(refreshedAt) < const Duration(seconds: 15)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final endpoint = _resolveWebSocketEndpoint();
|
||||
final endpoint = _resolveWebSocketEndpoint(provider);
|
||||
if (endpoint == null) {
|
||||
_cachedCapabilities = const DirectSingleAgentCapabilities.unavailable(
|
||||
final unavailable = const DirectSingleAgentCapabilities.unavailable(
|
||||
endpoint: '',
|
||||
errorMessage: 'Single-agent app-server endpoint is not configured.',
|
||||
);
|
||||
_capabilitiesRefreshedAt = DateTime.now();
|
||||
return _cachedCapabilities;
|
||||
_cachedCapabilities[provider] = unavailable;
|
||||
_capabilitiesRefreshedAt[provider] = DateTime.now();
|
||||
return unavailable;
|
||||
}
|
||||
|
||||
_DirectAppServerConnection? connection;
|
||||
@ -96,28 +110,28 @@ class DirectSingleAgentAppServerClient {
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
await connection.initialize();
|
||||
_cachedCapabilities = DirectSingleAgentCapabilities(
|
||||
_cachedCapabilities[provider] = DirectSingleAgentCapabilities(
|
||||
available: true,
|
||||
supportsCodex: true,
|
||||
supportedProviders: <SingleAgentProvider>[provider],
|
||||
endpoint: endpoint.toString(),
|
||||
);
|
||||
} catch (error) {
|
||||
_cachedCapabilities = DirectSingleAgentCapabilities.unavailable(
|
||||
_cachedCapabilities[provider] = DirectSingleAgentCapabilities.unavailable(
|
||||
endpoint: endpoint.toString(),
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
} finally {
|
||||
_capabilitiesRefreshedAt = DateTime.now();
|
||||
_capabilitiesRefreshedAt[provider] = DateTime.now();
|
||||
await connection?.close();
|
||||
}
|
||||
|
||||
return _cachedCapabilities;
|
||||
return _cachedCapabilities[provider]!;
|
||||
}
|
||||
|
||||
Future<DirectSingleAgentRunResult> run(
|
||||
DirectSingleAgentRunRequest request,
|
||||
) async {
|
||||
final endpoint = _resolveWebSocketEndpoint();
|
||||
final endpoint = _resolveWebSocketEndpoint(request.provider);
|
||||
if (endpoint == null) {
|
||||
return const DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
@ -334,8 +348,8 @@ class DirectSingleAgentAppServerClient {
|
||||
return threadId;
|
||||
}
|
||||
|
||||
Uri? _resolveWebSocketEndpoint() {
|
||||
final base = endpointResolver();
|
||||
Uri? _resolveWebSocketEndpoint(SingleAgentProvider provider) {
|
||||
final base = endpointResolver(provider);
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
@ -378,15 +392,16 @@ class _DirectAppServerConnection {
|
||||
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 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;
|
||||
@ -399,10 +414,7 @@ class _DirectAppServerConnection {
|
||||
await request(
|
||||
'initialize',
|
||||
params: const <String, dynamic>{
|
||||
'clientInfo': <String, dynamic>{
|
||||
'name': 'xworkmate',
|
||||
'version': '0',
|
||||
},
|
||||
'clientInfo': <String, dynamic>{'name': 'xworkmate', 'version': '0'},
|
||||
'capabilities': <String, dynamic>{
|
||||
'optOutNotificationMethods': <String>[],
|
||||
},
|
||||
@ -432,7 +444,9 @@ class _DirectAppServerConnection {
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
_pendingRequests.remove(id);
|
||||
throw TimeoutException('Single-agent app-server request $method timed out.');
|
||||
throw TimeoutException(
|
||||
'Single-agent app-server request $method timed out.',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -107,6 +107,135 @@ extension SingleAgentProviderCopy on SingleAgentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
const List<SingleAgentProvider> kBuiltinExternalAcpProviders =
|
||||
<SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
SingleAgentProvider.opencode,
|
||||
SingleAgentProvider.claude,
|
||||
SingleAgentProvider.gemini,
|
||||
];
|
||||
|
||||
class ExternalAcpEndpointProfile {
|
||||
const ExternalAcpEndpointProfile({
|
||||
required this.providerKey,
|
||||
required this.label,
|
||||
required this.endpoint,
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
final String providerKey;
|
||||
final String label;
|
||||
final String endpoint;
|
||||
final bool enabled;
|
||||
|
||||
factory ExternalAcpEndpointProfile.defaultsForProvider(
|
||||
SingleAgentProvider provider,
|
||||
) {
|
||||
return ExternalAcpEndpointProfile(
|
||||
providerKey: provider.providerId,
|
||||
label: provider.label,
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
);
|
||||
}
|
||||
|
||||
ExternalAcpEndpointProfile copyWith({
|
||||
String? providerKey,
|
||||
String? label,
|
||||
String? endpoint,
|
||||
bool? enabled,
|
||||
}) {
|
||||
return ExternalAcpEndpointProfile(
|
||||
providerKey: (providerKey ?? this.providerKey).trim(),
|
||||
label: (label ?? this.label).trim(),
|
||||
endpoint: (endpoint ?? this.endpoint).trim(),
|
||||
enabled: enabled ?? this.enabled,
|
||||
);
|
||||
}
|
||||
|
||||
SingleAgentProvider? get builtinProvider {
|
||||
final normalized = providerKey.trim().toLowerCase();
|
||||
for (final provider in kBuiltinExternalAcpProviders) {
|
||||
if (provider.providerId == normalized) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get isBuiltin => builtinProvider != null;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'providerKey': providerKey,
|
||||
'label': label,
|
||||
'endpoint': endpoint,
|
||||
'enabled': enabled,
|
||||
};
|
||||
}
|
||||
|
||||
factory ExternalAcpEndpointProfile.fromJson(Map<String, dynamic> json) {
|
||||
final providerKey = json['providerKey']?.toString().trim() ?? '';
|
||||
final builtin = SingleAgentProviderCopy.fromJsonValue(providerKey);
|
||||
final fallbackLabel = builtin == SingleAgentProvider.auto
|
||||
? providerKey
|
||||
: builtin.label;
|
||||
return ExternalAcpEndpointProfile(
|
||||
providerKey: providerKey,
|
||||
label: json['label']?.toString().trim().isNotEmpty == true
|
||||
? json['label'].toString().trim()
|
||||
: fallbackLabel,
|
||||
endpoint: json['endpoint']?.toString().trim() ?? '',
|
||||
enabled: json['enabled'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<ExternalAcpEndpointProfile> normalizeExternalAcpEndpoints({
|
||||
Iterable<ExternalAcpEndpointProfile>? profiles,
|
||||
}) {
|
||||
final incoming =
|
||||
profiles?.toList(growable: false) ?? const <ExternalAcpEndpointProfile>[];
|
||||
final byKey = <String, ExternalAcpEndpointProfile>{};
|
||||
for (final item in incoming) {
|
||||
final key = item.providerKey.trim().toLowerCase();
|
||||
if (key.isEmpty || byKey.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
byKey[key] = item.copyWith(providerKey: key);
|
||||
}
|
||||
|
||||
final normalized = <ExternalAcpEndpointProfile>[
|
||||
for (final provider in kBuiltinExternalAcpProviders)
|
||||
byKey.remove(provider.providerId) ??
|
||||
ExternalAcpEndpointProfile.defaultsForProvider(provider),
|
||||
...byKey.values,
|
||||
];
|
||||
return List<ExternalAcpEndpointProfile>.unmodifiable(normalized);
|
||||
}
|
||||
|
||||
List<ExternalAcpEndpointProfile> replaceExternalAcpEndpointForProvider(
|
||||
List<ExternalAcpEndpointProfile> profiles,
|
||||
SingleAgentProvider provider,
|
||||
ExternalAcpEndpointProfile profile,
|
||||
) {
|
||||
final normalized = normalizeExternalAcpEndpoints(profiles: profiles);
|
||||
final next = List<ExternalAcpEndpointProfile>.from(normalized);
|
||||
final index = next.indexWhere(
|
||||
(item) => item.providerKey.trim().toLowerCase() == provider.providerId,
|
||||
);
|
||||
final resolved = profile.copyWith(
|
||||
providerKey: provider.providerId,
|
||||
label: profile.label.trim().isEmpty ? provider.label : profile.label,
|
||||
);
|
||||
if (index == -1) {
|
||||
next.add(resolved);
|
||||
} else {
|
||||
next[index] = resolved;
|
||||
}
|
||||
return normalizeExternalAcpEndpoints(profiles: next);
|
||||
}
|
||||
|
||||
class AssistantThreadConnectionState {
|
||||
const AssistantThreadConnectionState({
|
||||
required this.executionTarget,
|
||||
@ -1165,6 +1294,7 @@ class SettingsSnapshot {
|
||||
required this.defaultModel,
|
||||
required this.defaultProvider,
|
||||
required this.gatewayProfiles,
|
||||
required this.externalAcpEndpoints,
|
||||
required this.ollamaLocal,
|
||||
required this.ollamaCloud,
|
||||
required this.vault,
|
||||
@ -1199,6 +1329,7 @@ class SettingsSnapshot {
|
||||
final String defaultModel;
|
||||
final String defaultProvider;
|
||||
final List<GatewayConnectionProfile> gatewayProfiles;
|
||||
final List<ExternalAcpEndpointProfile> externalAcpEndpoints;
|
||||
final OllamaLocalConfig ollamaLocal;
|
||||
final OllamaCloudConfig ollamaCloud;
|
||||
final VaultConfig vault;
|
||||
@ -1234,6 +1365,7 @@ class SettingsSnapshot {
|
||||
defaultModel: '',
|
||||
defaultProvider: 'gateway',
|
||||
gatewayProfiles: normalizeGatewayProfiles(),
|
||||
externalAcpEndpoints: normalizeExternalAcpEndpoints(),
|
||||
ollamaLocal: OllamaLocalConfig.defaults(),
|
||||
ollamaCloud: OllamaCloudConfig.defaults(),
|
||||
vault: VaultConfig.defaults(),
|
||||
@ -1270,6 +1402,7 @@ class SettingsSnapshot {
|
||||
String? defaultModel,
|
||||
String? defaultProvider,
|
||||
List<GatewayConnectionProfile>? gatewayProfiles,
|
||||
List<ExternalAcpEndpointProfile>? externalAcpEndpoints,
|
||||
OllamaLocalConfig? ollamaLocal,
|
||||
OllamaCloudConfig? ollamaCloud,
|
||||
VaultConfig? vault,
|
||||
@ -1294,6 +1427,9 @@ class SettingsSnapshot {
|
||||
final resolvedGatewayProfiles = gatewayProfiles != null
|
||||
? normalizeGatewayProfiles(profiles: gatewayProfiles)
|
||||
: this.gatewayProfiles;
|
||||
final resolvedExternalAcpEndpoints = externalAcpEndpoints != null
|
||||
? normalizeExternalAcpEndpoints(profiles: externalAcpEndpoints)
|
||||
: this.externalAcpEndpoints;
|
||||
return SettingsSnapshot(
|
||||
appLanguage: appLanguage ?? this.appLanguage,
|
||||
appActive: appActive ?? this.appActive,
|
||||
@ -1307,6 +1443,7 @@ class SettingsSnapshot {
|
||||
defaultModel: defaultModel ?? this.defaultModel,
|
||||
defaultProvider: defaultProvider ?? this.defaultProvider,
|
||||
gatewayProfiles: resolvedGatewayProfiles,
|
||||
externalAcpEndpoints: resolvedExternalAcpEndpoints,
|
||||
ollamaLocal: ollamaLocal ?? this.ollamaLocal,
|
||||
ollamaCloud: ollamaCloud ?? this.ollamaCloud,
|
||||
vault: vault ?? this.vault,
|
||||
@ -1354,6 +1491,9 @@ class SettingsSnapshot {
|
||||
'gatewayProfiles': gatewayProfiles
|
||||
.map((item) => item.toJson())
|
||||
.toList(growable: false),
|
||||
'externalAcpEndpoints': externalAcpEndpoints
|
||||
.map((item) => item.toJson())
|
||||
.toList(growable: false),
|
||||
'ollamaLocal': ollamaLocal.toJson(),
|
||||
'ollamaCloud': ollamaCloud.toJson(),
|
||||
'vault': vault.toJson(),
|
||||
@ -1433,6 +1573,15 @@ class SettingsSnapshot {
|
||||
GatewayConnectionProfile.fromJson(item.cast<String, dynamic>()),
|
||||
),
|
||||
);
|
||||
final externalAcpEndpoints = normalizeExternalAcpEndpoints(
|
||||
profiles: ((json['externalAcpEndpoints'] as List?) ?? const <Object>[])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => ExternalAcpEndpointProfile.fromJson(
|
||||
item.cast<String, dynamic>(),
|
||||
),
|
||||
),
|
||||
);
|
||||
return SettingsSnapshot(
|
||||
appLanguage: AppLanguageCopy.fromJsonValue(
|
||||
json['appLanguage'] as String?,
|
||||
@ -1461,6 +1610,7 @@ class SettingsSnapshot {
|
||||
json['defaultProvider'] as String? ??
|
||||
SettingsSnapshot.defaults().defaultProvider,
|
||||
gatewayProfiles: gatewayProfiles,
|
||||
externalAcpEndpoints: externalAcpEndpoints,
|
||||
ollamaLocal: OllamaLocalConfig.fromJson(
|
||||
(json['ollamaLocal'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
),
|
||||
@ -1566,6 +1716,28 @@ class SettingsSnapshot {
|
||||
}
|
||||
return copyWithGatewayProfileAt(index, profile);
|
||||
}
|
||||
|
||||
ExternalAcpEndpointProfile externalAcpEndpointForProvider(
|
||||
SingleAgentProvider provider,
|
||||
) {
|
||||
return externalAcpEndpoints.firstWhere(
|
||||
(item) => item.providerKey.trim().toLowerCase() == provider.providerId,
|
||||
orElse: () => ExternalAcpEndpointProfile.defaultsForProvider(provider),
|
||||
);
|
||||
}
|
||||
|
||||
SettingsSnapshot copyWithExternalAcpEndpointForProvider(
|
||||
SingleAgentProvider provider,
|
||||
ExternalAcpEndpointProfile profile,
|
||||
) {
|
||||
return copyWith(
|
||||
externalAcpEndpoints: replaceExternalAcpEndpointForProvider(
|
||||
externalAcpEndpoints,
|
||||
provider,
|
||||
profile,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GatewayConnectionSnapshot {
|
||||
|
||||
@ -92,31 +92,49 @@ class DefaultSingleAgentRunner implements SingleAgentRunner {
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
try {
|
||||
final capabilities = await _appServerClient.loadCapabilities(
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
if (!capabilities.available || !capabilities.supportsCodex) {
|
||||
if (selection != SingleAgentProvider.auto) {
|
||||
final capabilities = await _appServerClient.loadCapabilities(
|
||||
provider: selection,
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
if (!capabilities.available ||
|
||||
!capabilities.supportsProvider(selection)) {
|
||||
return SingleAgentProviderResolution(
|
||||
selection: selection,
|
||||
resolvedProvider: null,
|
||||
fallbackReason:
|
||||
capabilities.errorMessage ??
|
||||
'${selection.label} endpoint is unavailable.',
|
||||
);
|
||||
}
|
||||
return SingleAgentProviderResolution(
|
||||
selection: selection,
|
||||
resolvedProvider: null,
|
||||
fallbackReason:
|
||||
capabilities.errorMessage ??
|
||||
'Single-agent app-server is unavailable.',
|
||||
resolvedProvider: selection,
|
||||
fallbackReason: null,
|
||||
);
|
||||
}
|
||||
if (selection != SingleAgentProvider.auto &&
|
||||
selection != SingleAgentProvider.codex) {
|
||||
return SingleAgentProviderResolution(
|
||||
selection: selection,
|
||||
resolvedProvider: null,
|
||||
fallbackReason:
|
||||
'${selection.label} is unavailable from the direct app-server endpoint.',
|
||||
|
||||
String? fallbackReason;
|
||||
for (final provider in kBuiltinExternalAcpProviders) {
|
||||
final capabilities = await _appServerClient.loadCapabilities(
|
||||
provider: provider,
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
if (capabilities.available && capabilities.supportsProvider(provider)) {
|
||||
return SingleAgentProviderResolution(
|
||||
selection: selection,
|
||||
resolvedProvider: provider,
|
||||
fallbackReason: null,
|
||||
);
|
||||
}
|
||||
fallbackReason ??= capabilities.errorMessage;
|
||||
}
|
||||
return SingleAgentProviderResolution(
|
||||
selection: selection,
|
||||
resolvedProvider: SingleAgentProvider.codex,
|
||||
fallbackReason: null,
|
||||
resolvedProvider: null,
|
||||
fallbackReason:
|
||||
fallbackReason ??
|
||||
'No external ACP endpoint is currently available.',
|
||||
);
|
||||
} catch (error) {
|
||||
return SingleAgentProviderResolution(
|
||||
@ -133,6 +151,7 @@ class DefaultSingleAgentRunner implements SingleAgentRunner {
|
||||
final result = await _appServerClient.run(
|
||||
DirectSingleAgentRunRequest(
|
||||
sessionId: request.sessionId,
|
||||
provider: request.provider,
|
||||
prompt: _augmentPrompt(request),
|
||||
model: request.model,
|
||||
workingDirectory: request.workingDirectory,
|
||||
|
||||
@ -147,9 +147,11 @@ void main() {
|
||||
await tester.tap(find.text('集成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('OpenClaw Gateway'), findsOneWidget);
|
||||
expect(find.text('OpenClaw Gateway'), findsWidgets);
|
||||
expect(find.text('LLM 接入点'), findsOneWidget);
|
||||
expect(find.text('ACP 外部接入'), findsOneWidget);
|
||||
expect(find.text('Vault Server'), findsNothing);
|
||||
expect(find.byKey(const ValueKey('ai-gateway-url-field')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('ai-gateway-url-field')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('gateway-mode-field')), findsNothing);
|
||||
expect(find.text('认证诊断'), findsNothing);
|
||||
expect(find.byKey(const ValueKey('gateway-test-button')), findsOneWidget);
|
||||
@ -216,6 +218,30 @@ void main() {
|
||||
expect(find.text('Vault Server'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage integration tab exposes ACP provider endpoints', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('集成'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('ACP 外部接入').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('外部 ACP Server Endpoint'), findsOneWidget);
|
||||
expect(find.text('Codex'), findsOneWidget);
|
||||
expect(find.text('OpenCode'), findsOneWidget);
|
||||
expect(find.text('Claude'), findsOneWidget);
|
||||
expect(find.text('Gemini'), findsOneWidget);
|
||||
expect(find.textContaining('ws://127.0.0.1:9001'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage gateway sections can collapse individually', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@ -230,7 +256,7 @@ void main() {
|
||||
await tester.tap(find.text('集成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('OpenClaw Gateway'));
|
||||
await tester.tap(find.byTooltip('折叠').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('gateway-host-field')), findsNothing);
|
||||
@ -240,7 +266,7 @@ void main() {
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('OpenClaw Gateway'));
|
||||
await tester.tap(find.byTooltip('展开').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('gateway-host-field')), findsOneWidget);
|
||||
|
||||
@ -7,6 +7,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/direct_single_agent_app_server_client.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
void main() {
|
||||
group('DirectSingleAgentAppServerClient', () {
|
||||
@ -15,10 +16,12 @@ void main() {
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: () => server.baseHttpUri,
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
|
||||
final capabilities = await client.loadCapabilities();
|
||||
final capabilities = await client.loadCapabilities(
|
||||
provider: SingleAgentProvider.codex,
|
||||
);
|
||||
|
||||
expect(capabilities.available, isTrue);
|
||||
expect(capabilities.supportsCodex, isTrue);
|
||||
@ -31,7 +34,7 @@ void main() {
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: () => server.baseHttpUri,
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
@ -39,6 +42,7 @@ void main() {
|
||||
final result = await client.run(
|
||||
const DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-1',
|
||||
provider: SingleAgentProvider.codex,
|
||||
prompt: 'hello world',
|
||||
model: 'gpt-4.1',
|
||||
workingDirectory: '/tmp',
|
||||
@ -49,11 +53,10 @@ void main() {
|
||||
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.methods,
|
||||
containsAll(<String>['initialize', 'thread/start', 'turn/start']),
|
||||
);
|
||||
expect(server.authorizationHeaders, contains('Bearer token-1'));
|
||||
});
|
||||
|
||||
@ -62,13 +65,14 @@ void main() {
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: () => server.baseHttpUri,
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
final runFuture = client.run(
|
||||
const DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-abort',
|
||||
provider: SingleAgentProvider.codex,
|
||||
prompt: 'abort me',
|
||||
model: 'gpt-4.1',
|
||||
workingDirectory: '/tmp',
|
||||
@ -93,7 +97,8 @@ class _FakeAppServer {
|
||||
final bool delayCompletion;
|
||||
final List<String> methods = <String>[];
|
||||
final List<String> authorizationHeaders = <String>[];
|
||||
final Map<String, Completer<void>> _methodWaiters = <String, Completer<void>>{};
|
||||
final Map<String, Completer<void>> _methodWaiters =
|
||||
<String, Completer<void>>{};
|
||||
int _threadCounter = 0;
|
||||
|
||||
int get port => _server.port;
|
||||
@ -123,7 +128,8 @@ class _FakeAppServer {
|
||||
authorizationHeaders.add(
|
||||
request.headers.value(HttpHeaders.authorizationHeader) ?? '',
|
||||
);
|
||||
if (request.uri.path == '/' && WebSocketTransformer.isUpgradeRequest(request)) {
|
||||
if (request.uri.path == '/' &&
|
||||
WebSocketTransformer.isUpgradeRequest(request)) {
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
unawaited(_handleSocket(socket));
|
||||
continue;
|
||||
@ -146,78 +152,92 @@ class _FakeAppServer {
|
||||
_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'},
|
||||
},
|
||||
}));
|
||||
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,
|
||||
},
|
||||
}));
|
||||
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,
|
||||
},
|
||||
}));
|
||||
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',
|
||||
},
|
||||
}));
|
||||
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',
|
||||
},
|
||||
}));
|
||||
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',
|
||||
},
|
||||
}));
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'error': <String, dynamic>{
|
||||
'code': -32601,
|
||||
'message': 'unknown method $method',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -226,15 +246,17 @@ class _FakeAppServer {
|
||||
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,
|
||||
},
|
||||
}));
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'item/agentMessage/delta',
|
||||
'params': <String, dynamic>{
|
||||
'threadId': threadId,
|
||||
'turnId': 'turn-1',
|
||||
'delta': part,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
@ -243,14 +265,13 @@ class _FakeAppServer {
|
||||
if (delayCompletion) {
|
||||
return;
|
||||
}
|
||||
socket.add(jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'turn/completed',
|
||||
'params': <String, dynamic>{
|
||||
'threadId': threadId,
|
||||
'turnId': 'turn-1',
|
||||
},
|
||||
}));
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'turn/completed',
|
||||
'params': <String, dynamic>{'threadId': threadId, 'turnId': 'turn-1'},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -282,11 +303,10 @@ Map<String, dynamic> _asMap(Object? value) {
|
||||
}
|
||||
|
||||
extension on DirectSingleAgentRunRequest {
|
||||
DirectSingleAgentRunRequest copyWith({
|
||||
void Function(String text)? onOutput,
|
||||
}) {
|
||||
DirectSingleAgentRunRequest copyWith({void Function(String text)? onOutput}) {
|
||||
return DirectSingleAgentRunRequest(
|
||||
sessionId: sessionId,
|
||||
provider: provider,
|
||||
prompt: prompt,
|
||||
model: model,
|
||||
workingDirectory: workingDirectory,
|
||||
|
||||
65
test/runtime/external_acp_endpoint_settings_suite.dart
Normal file
65
test/runtime/external_acp_endpoint_settings_suite.dart
Normal file
@ -0,0 +1,65 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
void main() {
|
||||
group('External ACP endpoint settings', () {
|
||||
test('defaults expose the first batch of built-in providers', () {
|
||||
final snapshot = SettingsSnapshot.defaults();
|
||||
|
||||
expect(
|
||||
snapshot.externalAcpEndpoints
|
||||
.take(4)
|
||||
.map((item) => item.providerKey)
|
||||
.toList(growable: false),
|
||||
const <String>['codex', 'opencode', 'claude', 'gemini'],
|
||||
);
|
||||
});
|
||||
|
||||
test('round-trip preserves built-in entries and custom extensions', () {
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
externalAcpEndpoints: normalizeExternalAcpEndpoints(
|
||||
profiles: <ExternalAcpEndpointProfile>[
|
||||
ExternalAcpEndpointProfile.defaultsForProvider(
|
||||
SingleAgentProvider.codex,
|
||||
).copyWith(endpoint: 'ws://127.0.0.1:9001'),
|
||||
ExternalAcpEndpointProfile.defaultsForProvider(
|
||||
SingleAgentProvider.opencode,
|
||||
).copyWith(endpoint: 'https://opencode.example.com'),
|
||||
const ExternalAcpEndpointProfile(
|
||||
providerKey: 'custom-lab',
|
||||
label: 'Custom Lab',
|
||||
endpoint: 'wss://lab.example.com/acp',
|
||||
enabled: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final decoded = SettingsSnapshot.fromJson(snapshot.toJson());
|
||||
|
||||
expect(
|
||||
decoded
|
||||
.externalAcpEndpointForProvider(SingleAgentProvider.codex)
|
||||
.endpoint,
|
||||
'ws://127.0.0.1:9001',
|
||||
);
|
||||
expect(
|
||||
decoded
|
||||
.externalAcpEndpointForProvider(SingleAgentProvider.opencode)
|
||||
.endpoint,
|
||||
'https://opencode.example.com',
|
||||
);
|
||||
expect(
|
||||
decoded.externalAcpEndpoints.any(
|
||||
(item) =>
|
||||
item.providerKey == 'custom-lab' &&
|
||||
item.endpoint == 'wss://lab.example.com/acp',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user