refactor(runtime): retire legacy direct single-agent path
This commit is contained in:
parent
bd2d7a9d26
commit
20cf61cd6b
@ -1,4 +1,6 @@
|
||||
// Legacy compatibility surface retained while the app imports are cleaned up.
|
||||
//
|
||||
// The direct single-agent app-server runtime has been retired in favor of the
|
||||
// GoAgentCore ACP path. This library intentionally exports only the capability
|
||||
// DTOs still consumed by the UI-facing state layer.
|
||||
export 'direct_single_agent_app_server_client_protocol.dart';
|
||||
export 'direct_single_agent_app_server_client_transport.dart';
|
||||
export 'direct_single_agent_app_server_client_helpers.dart';
|
||||
export 'direct_single_agent_app_server_client_core.dart';
|
||||
|
||||
@ -1,224 +0,0 @@
|
||||
// ignore_for_file: unused_import, unnecessary_import
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'runtime_models.dart';
|
||||
import 'direct_single_agent_app_server_client_protocol.dart';
|
||||
import 'direct_single_agent_app_server_client_transport.dart';
|
||||
import 'direct_single_agent_app_server_client_helpers.dart';
|
||||
|
||||
class DirectSingleAgentAppServerClient {
|
||||
DirectSingleAgentAppServerClient({required this.endpointResolver});
|
||||
|
||||
final Uri? Function(SingleAgentProvider provider) endpointResolver;
|
||||
final DirectSingleAgentWebSocketTransportInternal webSocketTransportInternal =
|
||||
DirectSingleAgentWebSocketTransportInternal();
|
||||
final DirectSingleAgentRestTransportInternal restTransportInternal =
|
||||
DirectSingleAgentRestTransportInternal();
|
||||
|
||||
final Map<SingleAgentProvider, DirectSingleAgentCapabilities>
|
||||
cachedCapabilitiesInternal =
|
||||
<SingleAgentProvider, DirectSingleAgentCapabilities>{};
|
||||
final Map<SingleAgentProvider, DateTime> capabilitiesRefreshedAtInternal =
|
||||
<SingleAgentProvider, DateTime>{};
|
||||
final Map<SingleAgentProvider, DirectSingleAgentTransportKindInternal>
|
||||
transportKindsInternal =
|
||||
<SingleAgentProvider, DirectSingleAgentTransportKindInternal>{};
|
||||
|
||||
Future<DirectSingleAgentCapabilities> loadCapabilities({
|
||||
required SingleAgentProvider provider,
|
||||
bool forceRefresh = false,
|
||||
String gatewayToken = '',
|
||||
}) async {
|
||||
final cached = cachedCapabilitiesInternal[provider];
|
||||
final refreshedAt = capabilitiesRefreshedAtInternal[provider];
|
||||
if (!forceRefresh &&
|
||||
cached != null &&
|
||||
refreshedAt != null &&
|
||||
DateTime.now().difference(refreshedAt) < const Duration(seconds: 15)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final descriptor = describeEndpointInternal(provider);
|
||||
if (!descriptor.isSupported || descriptor.baseUri == null) {
|
||||
final unavailable = const DirectSingleAgentCapabilities.unavailable(
|
||||
endpoint: '',
|
||||
errorMessage: 'Single-agent app-server endpoint is not configured.',
|
||||
);
|
||||
cachedCapabilitiesInternal[provider] = unavailable;
|
||||
capabilitiesRefreshedAtInternal[provider] = DateTime.now();
|
||||
return unavailable;
|
||||
}
|
||||
|
||||
try {
|
||||
final transport = await resolveTransportInternal(
|
||||
provider,
|
||||
descriptor: descriptor,
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
transportKindsInternal[provider] = transport.kind;
|
||||
cachedCapabilitiesInternal[provider] = DirectSingleAgentCapabilities(
|
||||
available: true,
|
||||
supportedProviders: <SingleAgentProvider>[provider],
|
||||
endpoint: transport.endpoint.toString(),
|
||||
);
|
||||
} catch (error) {
|
||||
cachedCapabilitiesInternal[provider] =
|
||||
DirectSingleAgentCapabilities.unavailable(
|
||||
endpoint: descriptor.baseUri.toString(),
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
transportKindsInternal.remove(provider);
|
||||
} finally {
|
||||
capabilitiesRefreshedAtInternal[provider] = DateTime.now();
|
||||
}
|
||||
|
||||
return cachedCapabilitiesInternal[provider]!;
|
||||
}
|
||||
|
||||
Future<DirectSingleAgentRunResult> run(
|
||||
DirectSingleAgentRunRequest request,
|
||||
) async {
|
||||
final descriptor = describeEndpointInternal(request.provider);
|
||||
if (!descriptor.isSupported || descriptor.baseUri == null) {
|
||||
return const DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: '',
|
||||
errorMessage: 'Single-agent app-server endpoint is missing.',
|
||||
);
|
||||
}
|
||||
late final ResolvedSingleAgentTransportInternal transport;
|
||||
try {
|
||||
transport = await resolveTransportInternal(
|
||||
request.provider,
|
||||
descriptor: descriptor,
|
||||
gatewayToken: request.gatewayToken,
|
||||
);
|
||||
} catch (error) {
|
||||
return DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: '',
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
}
|
||||
if (transport.kind ==
|
||||
DirectSingleAgentTransportKindInternal.restSessionApi) {
|
||||
return transport.rest!.run(
|
||||
request,
|
||||
base: transport.endpoint,
|
||||
);
|
||||
}
|
||||
return transport.websocket!.run(
|
||||
request,
|
||||
endpoint: transport.endpoint,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> abort(String sessionId) async {
|
||||
await restTransportInternal.abort(
|
||||
sessionId,
|
||||
candidateBases: <Uri>[
|
||||
for (final entry in transportKindsInternal.entries)
|
||||
if (entry.value ==
|
||||
DirectSingleAgentTransportKindInternal.restSessionApi) ...[
|
||||
if (describeEndpointInternal(entry.key).baseUri != null)
|
||||
describeEndpointInternal(entry.key).baseUri!,
|
||||
],
|
||||
],
|
||||
);
|
||||
await webSocketTransportInternal.abort(sessionId);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await webSocketTransportInternal.dispose();
|
||||
}
|
||||
|
||||
DirectSingleAgentEndpointDescriptor describeEndpointInternal(
|
||||
SingleAgentProvider provider,
|
||||
) {
|
||||
return DirectSingleAgentEndpointDescriptor.describe(
|
||||
endpointResolver(provider),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ResolvedSingleAgentTransportInternal> resolveTransportInternal(
|
||||
SingleAgentProvider provider, {
|
||||
required DirectSingleAgentEndpointDescriptor descriptor,
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
final cachedKind = transportKindsInternal[provider];
|
||||
if (cachedKind != null) {
|
||||
final cachedEndpoint =
|
||||
cachedKind ==
|
||||
DirectSingleAgentTransportKindInternal.websocketAppServer
|
||||
? descriptor.websocketUri
|
||||
: descriptor.baseUri;
|
||||
if (cachedEndpoint != null) {
|
||||
return ResolvedSingleAgentTransportInternal(
|
||||
kind: cachedKind,
|
||||
endpoint: cachedEndpoint,
|
||||
websocket:
|
||||
cachedKind ==
|
||||
DirectSingleAgentTransportKindInternal.websocketAppServer
|
||||
? webSocketTransportInternal
|
||||
: null,
|
||||
rest:
|
||||
cachedKind ==
|
||||
DirectSingleAgentTransportKindInternal.restSessionApi
|
||||
? restTransportInternal
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (descriptor.prefersWebSocket) {
|
||||
final endpoint = descriptor.websocketUri;
|
||||
if (endpoint == null) {
|
||||
throw StateError('Single-agent websocket endpoint is not configured.');
|
||||
}
|
||||
await webSocketTransportInternal.probe(
|
||||
endpoint,
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
return ResolvedSingleAgentTransportInternal(
|
||||
kind: DirectSingleAgentTransportKindInternal.websocketAppServer,
|
||||
endpoint: endpoint,
|
||||
websocket: webSocketTransportInternal,
|
||||
);
|
||||
}
|
||||
|
||||
if (descriptor.allowsRest) {
|
||||
final base = descriptor.baseUri;
|
||||
if (base == null) {
|
||||
throw StateError('Single-agent endpoint is not configured.');
|
||||
}
|
||||
try {
|
||||
await restTransportInternal.probe(base, gatewayToken: gatewayToken);
|
||||
return ResolvedSingleAgentTransportInternal(
|
||||
kind: DirectSingleAgentTransportKindInternal.restSessionApi,
|
||||
endpoint: base,
|
||||
rest: restTransportInternal,
|
||||
);
|
||||
} catch (_) {
|
||||
final websocket = descriptor.websocketUri;
|
||||
if (websocket == null) {
|
||||
rethrow;
|
||||
}
|
||||
await webSocketTransportInternal.probe(
|
||||
websocket,
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
return ResolvedSingleAgentTransportInternal(
|
||||
kind: DirectSingleAgentTransportKindInternal.websocketAppServer,
|
||||
endpoint: websocket,
|
||||
websocket: webSocketTransportInternal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw StateError(
|
||||
'Single-agent endpoint mode ${descriptor.mode.name} is not supported.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,179 +0,0 @@
|
||||
// ignore_for_file: unused_import, unnecessary_import
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'runtime_models.dart';
|
||||
import 'direct_single_agent_app_server_client_protocol.dart';
|
||||
import 'direct_single_agent_app_server_client_transport.dart';
|
||||
import 'direct_single_agent_app_server_client_core.dart';
|
||||
|
||||
Uri buildRestUriInternal(
|
||||
Uri base,
|
||||
String path, {
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
final normalizedPath = path.startsWith('/') ? path : '/$path';
|
||||
return base.replace(
|
||||
path: normalizedPath,
|
||||
queryParameters: queryParameters,
|
||||
fragment: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fetchJsonInternal(
|
||||
Uri uri, {
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
|
||||
try {
|
||||
final request = await client.getUrl(uri);
|
||||
final normalizedToken = gatewayToken.trim();
|
||||
if (normalizedToken.isNotEmpty) {
|
||||
request.headers.set(
|
||||
HttpHeaders.authorizationHeader,
|
||||
'Bearer $normalizedToken',
|
||||
);
|
||||
}
|
||||
final response = await request.close();
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
return decodeMapInternal(body);
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> postJsonInternal(
|
||||
Uri uri, {
|
||||
required Object? body,
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
|
||||
try {
|
||||
final request = await client.postUrl(uri);
|
||||
request.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
final normalizedToken = gatewayToken.trim();
|
||||
if (normalizedToken.isNotEmpty) {
|
||||
request.headers.set(
|
||||
HttpHeaders.authorizationHeader,
|
||||
'Bearer $normalizedToken',
|
||||
);
|
||||
}
|
||||
if (body != null) {
|
||||
request.add(utf8.encode(jsonEncode(body)));
|
||||
}
|
||||
final response = await request.close();
|
||||
final text = await response.transform(utf8.decoder).join();
|
||||
if (text.trim().isEmpty) {
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
return decodeMapInternal(text);
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Object?>> fetchJsonListInternal(
|
||||
Uri uri, {
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
|
||||
try {
|
||||
final request = await client.getUrl(uri);
|
||||
final normalizedToken = gatewayToken.trim();
|
||||
if (normalizedToken.isNotEmpty) {
|
||||
request.headers.set(
|
||||
HttpHeaders.authorizationHeader,
|
||||
'Bearer $normalizedToken',
|
||||
);
|
||||
}
|
||||
final response = await request.close();
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is List<Object?>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is List) {
|
||||
return decoded.cast<Object?>();
|
||||
}
|
||||
return const <Object?>[];
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
String? extractThreadIdInternal(Map<String, dynamic> payload) {
|
||||
final topLevelId = payload['id']?.toString().trim() ?? '';
|
||||
if (topLevelId.isNotEmpty) {
|
||||
return topLevelId;
|
||||
}
|
||||
final thread = asMapInternal(payload['thread']);
|
||||
final nestedId = thread['id']?.toString().trim() ?? '';
|
||||
if (nestedId.isNotEmpty) {
|
||||
return nestedId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? extractModelInternal(Map<String, dynamic> payload) {
|
||||
final model = payload['model']?.toString().trim() ?? '';
|
||||
if (model.isNotEmpty) {
|
||||
return model;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? extractThreadPathInternal(Map<String, dynamic> payload) {
|
||||
final directPath = payload['path']?.toString().trim() ?? '';
|
||||
if (directPath.isNotEmpty) {
|
||||
return directPath;
|
||||
}
|
||||
final thread = asMapInternal(payload['thread']);
|
||||
final nestedPath = thread['path']?.toString().trim() ?? '';
|
||||
if (nestedPath.isNotEmpty) {
|
||||
return nestedPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> decodeMapInternal(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> asMapInternal(Object? value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.cast<String, dynamic>();
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
bool isLocalHostInternal(String host) {
|
||||
final normalized = host.trim().toLowerCase();
|
||||
if (normalized.isEmpty ||
|
||||
normalized == 'localhost' ||
|
||||
normalized == '127.0.0.1' ||
|
||||
normalized == '::1') {
|
||||
return true;
|
||||
}
|
||||
final address = InternetAddress.tryParse(normalized);
|
||||
return address?.isLoopback ?? false;
|
||||
}
|
||||
@ -1,12 +1,4 @@
|
||||
// ignore_for_file: unused_import, unnecessary_import
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'runtime_models.dart';
|
||||
import 'direct_single_agent_app_server_client_transport.dart';
|
||||
import 'direct_single_agent_app_server_client_helpers.dart';
|
||||
import 'direct_single_agent_app_server_client_core.dart';
|
||||
|
||||
class DirectSingleAgentCapabilities {
|
||||
const DirectSingleAgentCapabilities({
|
||||
@ -32,128 +24,3 @@ class DirectSingleAgentCapabilities {
|
||||
bool supportsProvider(SingleAgentProvider provider) =>
|
||||
supportedProviders.contains(provider);
|
||||
}
|
||||
|
||||
class DirectSingleAgentRunResult {
|
||||
const DirectSingleAgentRunResult({
|
||||
required this.success,
|
||||
required this.output,
|
||||
required this.errorMessage,
|
||||
this.aborted = false,
|
||||
this.resolvedModel = '',
|
||||
this.resolvedWorkingDirectory = '',
|
||||
this.resolvedWorkspaceRefKind = WorkspaceRefKind.localPath,
|
||||
});
|
||||
|
||||
final bool success;
|
||||
final String output;
|
||||
final String errorMessage;
|
||||
final bool aborted;
|
||||
final String resolvedModel;
|
||||
final String resolvedWorkingDirectory;
|
||||
final WorkspaceRefKind resolvedWorkspaceRefKind;
|
||||
}
|
||||
|
||||
class DirectSingleAgentRunRequest {
|
||||
const DirectSingleAgentRunRequest({
|
||||
required this.sessionId,
|
||||
required this.provider,
|
||||
required this.prompt,
|
||||
required this.model,
|
||||
required this.workingDirectory,
|
||||
required this.gatewayToken,
|
||||
this.selectedSkills = const <AssistantThreadSkillEntry>[],
|
||||
this.onOutput,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final SingleAgentProvider provider;
|
||||
final String prompt;
|
||||
final String model;
|
||||
final String workingDirectory;
|
||||
final String gatewayToken;
|
||||
final List<AssistantThreadSkillEntry> selectedSkills;
|
||||
final void Function(String text)? onOutput;
|
||||
}
|
||||
|
||||
enum DirectSingleAgentEndpointMode {
|
||||
wsLocal,
|
||||
wss,
|
||||
httpLocal,
|
||||
https,
|
||||
unsupported,
|
||||
}
|
||||
|
||||
enum DirectSingleAgentTransportKindInternal {
|
||||
websocketAppServer,
|
||||
restSessionApi,
|
||||
}
|
||||
|
||||
class DirectSingleAgentEndpointDescriptor {
|
||||
const DirectSingleAgentEndpointDescriptor({
|
||||
required this.mode,
|
||||
required this.baseUri,
|
||||
this.websocketUri,
|
||||
});
|
||||
|
||||
final DirectSingleAgentEndpointMode mode;
|
||||
final Uri? baseUri;
|
||||
final Uri? websocketUri;
|
||||
|
||||
bool get isSupported => mode != DirectSingleAgentEndpointMode.unsupported;
|
||||
|
||||
bool get prefersWebSocket =>
|
||||
mode == DirectSingleAgentEndpointMode.wsLocal ||
|
||||
mode == DirectSingleAgentEndpointMode.wss;
|
||||
|
||||
bool get allowsRest =>
|
||||
mode == DirectSingleAgentEndpointMode.httpLocal ||
|
||||
mode == DirectSingleAgentEndpointMode.https;
|
||||
|
||||
static DirectSingleAgentEndpointDescriptor describe(Uri? endpoint) {
|
||||
if (endpoint == null) {
|
||||
return const DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.unsupported,
|
||||
baseUri: null,
|
||||
);
|
||||
}
|
||||
final scheme = endpoint.scheme.toLowerCase();
|
||||
final normalizedBase = endpoint.replace(
|
||||
path: '',
|
||||
query: null,
|
||||
fragment: null,
|
||||
);
|
||||
final isLocal = isLocalHostInternal(endpoint.host);
|
||||
if (scheme == 'ws' && isLocal) {
|
||||
return DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.wsLocal,
|
||||
baseUri: normalizedBase,
|
||||
websocketUri: normalizedBase,
|
||||
);
|
||||
}
|
||||
if (scheme == 'wss') {
|
||||
return DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.wss,
|
||||
baseUri: normalizedBase,
|
||||
websocketUri: normalizedBase,
|
||||
);
|
||||
}
|
||||
if (scheme == 'http' && isLocal) {
|
||||
return DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.httpLocal,
|
||||
baseUri: normalizedBase,
|
||||
websocketUri: normalizedBase.replace(scheme: 'ws'),
|
||||
);
|
||||
}
|
||||
if (scheme == 'https') {
|
||||
return DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.https,
|
||||
baseUri: normalizedBase,
|
||||
websocketUri: normalizedBase.replace(scheme: 'wss'),
|
||||
);
|
||||
}
|
||||
return DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.unsupported,
|
||||
baseUri: normalizedBase,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,885 +0,0 @@
|
||||
// ignore_for_file: unused_import, unnecessary_import
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'runtime_models.dart';
|
||||
import 'direct_single_agent_app_server_client_protocol.dart';
|
||||
import 'direct_single_agent_app_server_client_helpers.dart';
|
||||
import 'direct_single_agent_app_server_client_core.dart';
|
||||
|
||||
class ResolvedSingleAgentTransportInternal {
|
||||
const ResolvedSingleAgentTransportInternal({
|
||||
required this.kind,
|
||||
required this.endpoint,
|
||||
this.websocket,
|
||||
this.rest,
|
||||
});
|
||||
|
||||
final DirectSingleAgentTransportKindInternal kind;
|
||||
final Uri endpoint;
|
||||
final DirectSingleAgentWebSocketTransportInternal? websocket;
|
||||
final DirectSingleAgentRestTransportInternal? rest;
|
||||
}
|
||||
|
||||
class ResolvedDirectThreadInternal {
|
||||
const ResolvedDirectThreadInternal({
|
||||
required this.threadId,
|
||||
this.workingDirectory = '',
|
||||
});
|
||||
|
||||
final String threadId;
|
||||
final String workingDirectory;
|
||||
}
|
||||
|
||||
class DirectSingleAgentWebSocketTransportInternal {
|
||||
final Map<String, DirectAppServerConnectionInternal>
|
||||
activeConnectionsInternal = <String, DirectAppServerConnectionInternal>{};
|
||||
final Map<String, String> threadIdsInternal = <String, String>{};
|
||||
final Map<String, String> threadWorkingDirectoriesInternal =
|
||||
<String, String>{};
|
||||
final Set<String> abortedSessionsInternal = <String>{};
|
||||
|
||||
Future<void> probe(Uri endpoint, {required String gatewayToken}) async {
|
||||
DirectAppServerConnectionInternal? connection;
|
||||
try {
|
||||
connection = await DirectAppServerConnectionInternal.connect(
|
||||
endpoint,
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
await connection.initialize();
|
||||
} finally {
|
||||
await connection?.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<DirectSingleAgentRunResult> run(
|
||||
DirectSingleAgentRunRequest request, {
|
||||
required Uri endpoint,
|
||||
}) async {
|
||||
final normalizedSessionId = request.sessionId.trim();
|
||||
if (normalizedSessionId.isEmpty) {
|
||||
return const DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: '',
|
||||
errorMessage: 'Single-agent session id is missing.',
|
||||
);
|
||||
}
|
||||
|
||||
abortedSessionsInternal.remove(normalizedSessionId);
|
||||
final connection = await DirectAppServerConnectionInternal.connect(
|
||||
endpoint,
|
||||
gatewayToken: request.gatewayToken,
|
||||
);
|
||||
activeConnectionsInternal[normalizedSessionId] = connection;
|
||||
|
||||
try {
|
||||
await connection.initialize();
|
||||
final resolvedThread = await ensureThreadInternal(
|
||||
connection,
|
||||
sessionId: normalizedSessionId,
|
||||
workingDirectory: request.workingDirectory,
|
||||
model: request.model,
|
||||
);
|
||||
final threadId = resolvedThread.threadId;
|
||||
final resolvedWorkingDirectory = resolvedThread.workingDirectory.trim();
|
||||
|
||||
final output = StringBuffer();
|
||||
String resolvedModel = '';
|
||||
final completion = Completer<DirectSingleAgentRunResult>();
|
||||
late final StreamSubscription<Map<String, dynamic>> subscription;
|
||||
subscription = connection.notifications.listen(
|
||||
(notification) {
|
||||
final method = notification['method']?.toString().trim() ?? '';
|
||||
final params = asMapInternal(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: '',
|
||||
resolvedModel: resolvedModel,
|
||||
resolvedWorkingDirectory: resolvedWorkingDirectory,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ((method == 'turn/failed' || method == 'turn/error') &&
|
||||
!completion.isCompleted) {
|
||||
final aborted =
|
||||
abortedSessionsInternal.contains(normalizedSessionId) ||
|
||||
(params['message']?.toString().toLowerCase().contains(
|
||||
'abort',
|
||||
) ??
|
||||
false);
|
||||
completion.complete(
|
||||
DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
aborted: aborted,
|
||||
resolvedModel: resolvedModel,
|
||||
resolvedWorkingDirectory: resolvedWorkingDirectory,
|
||||
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: abortedSessionsInternal.contains(normalizedSessionId),
|
||||
resolvedModel: resolvedModel,
|
||||
resolvedWorkingDirectory: resolvedWorkingDirectory,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!completion.isCompleted) {
|
||||
completion.complete(
|
||||
DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
errorMessage:
|
||||
abortedSessionsInternal.contains(normalizedSessionId)
|
||||
? 'Single-agent app-server run aborted.'
|
||||
: 'Single-agent app-server connection closed before completion.',
|
||||
aborted: abortedSessionsInternal.contains(normalizedSessionId),
|
||||
resolvedModel: resolvedModel,
|
||||
resolvedWorkingDirectory: resolvedWorkingDirectory,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
final input = <Map<String, dynamic>>[
|
||||
<String, dynamic>{'type': 'text', 'text': request.prompt},
|
||||
for (final skill in request.selectedSkills)
|
||||
if (skill.label.trim().isNotEmpty &&
|
||||
skill.sourcePath.trim().isNotEmpty)
|
||||
<String, dynamic>{
|
||||
'type': 'skill',
|
||||
'name': skill.label.trim(),
|
||||
'path': skill.sourcePath.trim(),
|
||||
},
|
||||
];
|
||||
final started = await connection.request(
|
||||
'turn/start',
|
||||
params: <String, dynamic>{'threadId': threadId, 'input': input},
|
||||
);
|
||||
resolvedModel = extractModelInternal(started) ?? resolvedModel;
|
||||
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: abortedSessionsInternal.contains(normalizedSessionId),
|
||||
resolvedModel: resolvedModel,
|
||||
resolvedWorkingDirectory: resolvedWorkingDirectory,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
}
|
||||
} catch (error) {
|
||||
return DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: '',
|
||||
errorMessage: error.toString(),
|
||||
aborted: abortedSessionsInternal.contains(normalizedSessionId),
|
||||
resolvedModel: '',
|
||||
resolvedWorkingDirectory: request.workingDirectory,
|
||||
);
|
||||
} finally {
|
||||
activeConnectionsInternal.remove(normalizedSessionId);
|
||||
await connection.close();
|
||||
abortedSessionsInternal.remove(normalizedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> abort(String sessionId) async {
|
||||
final normalizedSessionId = sessionId.trim();
|
||||
if (normalizedSessionId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
abortedSessionsInternal.add(normalizedSessionId);
|
||||
final connection = activeConnectionsInternal[normalizedSessionId];
|
||||
final threadId = threadIdsInternal[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 = activeConnectionsInternal.values.toList(
|
||||
growable: false,
|
||||
);
|
||||
activeConnectionsInternal.clear();
|
||||
for (final connection in connections) {
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<ResolvedDirectThreadInternal> ensureThreadInternal(
|
||||
DirectAppServerConnectionInternal connection, {
|
||||
required String sessionId,
|
||||
required String workingDirectory,
|
||||
required String model,
|
||||
}) async {
|
||||
final normalizedWorkingDirectory = workingDirectory.trim();
|
||||
final existingThreadId = threadIdsInternal[sessionId]?.trim() ?? '';
|
||||
final existingWorkingDirectory =
|
||||
threadWorkingDirectoriesInternal[sessionId]?.trim() ?? '';
|
||||
final canReuseExistingThread =
|
||||
existingThreadId.isNotEmpty &&
|
||||
(normalizedWorkingDirectory.isEmpty ||
|
||||
(existingWorkingDirectory.isNotEmpty &&
|
||||
existingWorkingDirectory == normalizedWorkingDirectory));
|
||||
if (existingThreadId.isNotEmpty) {
|
||||
if (!canReuseExistingThread) {
|
||||
threadIdsInternal.remove(sessionId);
|
||||
threadWorkingDirectoriesInternal.remove(sessionId);
|
||||
}
|
||||
}
|
||||
if (canReuseExistingThread) {
|
||||
try {
|
||||
final resumed = await connection.request(
|
||||
'thread/resume',
|
||||
params: <String, dynamic>{
|
||||
'threadId': existingThreadId,
|
||||
if (normalizedWorkingDirectory.isNotEmpty)
|
||||
'cwd': normalizedWorkingDirectory,
|
||||
},
|
||||
);
|
||||
final resumedId = extractThreadIdInternal(resumed) ?? existingThreadId;
|
||||
final resumedWorkingDirectory =
|
||||
extractThreadPathInternal(resumed)?.trim() ??
|
||||
normalizedWorkingDirectory;
|
||||
threadIdsInternal[sessionId] = resumedId;
|
||||
if (resumedWorkingDirectory.isNotEmpty) {
|
||||
threadWorkingDirectoriesInternal[sessionId] = resumedWorkingDirectory;
|
||||
}
|
||||
return ResolvedDirectThreadInternal(
|
||||
threadId: resumedId,
|
||||
workingDirectory: resumedWorkingDirectory,
|
||||
);
|
||||
} catch (_) {
|
||||
threadIdsInternal.remove(sessionId);
|
||||
threadWorkingDirectoriesInternal.remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
final created = await connection.request(
|
||||
'thread/start',
|
||||
params: <String, dynamic>{
|
||||
if (normalizedWorkingDirectory.isNotEmpty)
|
||||
'cwd': normalizedWorkingDirectory,
|
||||
if (model.trim().isNotEmpty) 'model': model.trim(),
|
||||
},
|
||||
);
|
||||
final threadId = extractThreadIdInternal(created) ?? '';
|
||||
if (threadId.isEmpty) {
|
||||
throw StateError('Single-agent app-server returned an empty thread id.');
|
||||
}
|
||||
final createdWorkingDirectory =
|
||||
extractThreadPathInternal(created)?.trim() ??
|
||||
normalizedWorkingDirectory;
|
||||
threadIdsInternal[sessionId] = threadId;
|
||||
if (createdWorkingDirectory.isNotEmpty) {
|
||||
threadWorkingDirectoriesInternal[sessionId] = createdWorkingDirectory;
|
||||
}
|
||||
return ResolvedDirectThreadInternal(
|
||||
threadId: threadId,
|
||||
workingDirectory: createdWorkingDirectory,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DirectSingleAgentRestTransportInternal {
|
||||
final Map<String, String> restSessionIdsInternal = <String, String>{};
|
||||
final Map<String, String> restSessionWorkingDirectoriesInternal =
|
||||
<String, String>{};
|
||||
final Set<String> abortedSessionsInternal = <String>{};
|
||||
|
||||
Future<void> probe(Uri base, {required String gatewayToken}) async {
|
||||
await fetchJsonInternal(
|
||||
buildRestUriInternal(base, '/global/health'),
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<DirectSingleAgentRunResult> run(
|
||||
DirectSingleAgentRunRequest request, {
|
||||
required Uri base,
|
||||
}) async {
|
||||
final normalizedSessionId = request.sessionId.trim();
|
||||
if (normalizedSessionId.isEmpty) {
|
||||
return const DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: '',
|
||||
errorMessage: 'Single-agent session id is missing.',
|
||||
);
|
||||
}
|
||||
|
||||
abortedSessionsInternal.remove(normalizedSessionId);
|
||||
final remoteSessionId = await ensureRestSessionInternal(
|
||||
base,
|
||||
sessionId: normalizedSessionId,
|
||||
workingDirectory: request.workingDirectory,
|
||||
gatewayToken: request.gatewayToken,
|
||||
);
|
||||
|
||||
final output = StringBuffer();
|
||||
final completion = Completer<DirectSingleAgentRunResult>();
|
||||
String? activeAssistantMessageId;
|
||||
String? lastAssistantText;
|
||||
var busySeen = false;
|
||||
|
||||
void completeFailure(String message) {
|
||||
if (completion.isCompleted) {
|
||||
return;
|
||||
}
|
||||
completion.complete(
|
||||
DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
errorMessage: message,
|
||||
aborted: abortedSessionsInternal.contains(normalizedSessionId),
|
||||
resolvedModel: request.model,
|
||||
resolvedWorkingDirectory: request.workingDirectory,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final eventClient = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 8);
|
||||
late final HttpClientRequest eventRequest;
|
||||
late final HttpClientResponse eventResponse;
|
||||
StreamSubscription<String>? lineSubscription;
|
||||
|
||||
void completeSuccess() {
|
||||
if (completion.isCompleted) {
|
||||
return;
|
||||
}
|
||||
final resolvedOutput = output.toString().trim().isNotEmpty
|
||||
? output.toString()
|
||||
: (lastAssistantText ?? '');
|
||||
if (resolvedOutput.trim().isEmpty) {
|
||||
completeFailure(
|
||||
'OpenCode REST session completed without assistant content.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
completion.complete(
|
||||
DirectSingleAgentRunResult(
|
||||
success: true,
|
||||
output: resolvedOutput,
|
||||
errorMessage: '',
|
||||
resolvedModel: request.model,
|
||||
resolvedWorkingDirectory: request.workingDirectory,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final eventUri = buildRestUriInternal(base, '/global/event');
|
||||
eventRequest = await eventClient.getUrl(eventUri);
|
||||
eventRequest.headers.set(HttpHeaders.acceptHeader, 'text/event-stream');
|
||||
final normalizedToken = request.gatewayToken.trim();
|
||||
if (normalizedToken.isNotEmpty) {
|
||||
eventRequest.headers.set(
|
||||
HttpHeaders.authorizationHeader,
|
||||
'Bearer $normalizedToken',
|
||||
);
|
||||
}
|
||||
eventResponse = await eventRequest.close();
|
||||
lineSubscription = eventResponse
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
(line) {
|
||||
if (!line.startsWith('data: ')) {
|
||||
return;
|
||||
}
|
||||
final event = decodeMapInternal(line.substring(6));
|
||||
final payload = asMapInternal(event['payload']);
|
||||
final type = payload['type']?.toString().trim() ?? '';
|
||||
final properties = asMapInternal(payload['properties']);
|
||||
if (properties['sessionID']?.toString().trim() !=
|
||||
remoteSessionId) {
|
||||
return;
|
||||
}
|
||||
if (type == 'session.status') {
|
||||
final status = asMapInternal(properties['status']);
|
||||
final statusType = status['type']?.toString().trim() ?? '';
|
||||
if (statusType == 'busy') {
|
||||
busySeen = true;
|
||||
}
|
||||
if (statusType == 'idle' && busySeen) {
|
||||
completeSuccess();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type == 'session.idle' && busySeen) {
|
||||
completeSuccess();
|
||||
return;
|
||||
}
|
||||
if (type == 'session.error' && !completion.isCompleted) {
|
||||
final error = asMapInternal(properties['error']);
|
||||
completeFailure(
|
||||
error['message']?.toString() ??
|
||||
error['name']?.toString() ??
|
||||
'OpenCode session failed.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (type == 'message.updated') {
|
||||
final info = asMapInternal(properties['info']);
|
||||
if (info['role']?.toString().trim() == 'assistant') {
|
||||
activeAssistantMessageId = info['id']?.toString().trim();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type == 'message.part.delta') {
|
||||
final part = asMapInternal(properties['part']);
|
||||
if (activeAssistantMessageId != null &&
|
||||
part['messageID']?.toString().trim() ==
|
||||
activeAssistantMessageId) {
|
||||
final delta =
|
||||
properties['text']?.toString() ??
|
||||
properties['delta']?.toString() ??
|
||||
'';
|
||||
if (delta.isNotEmpty) {
|
||||
output.write(delta);
|
||||
request.onOutput?.call(delta);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type == 'message.part.updated') {
|
||||
final part = asMapInternal(properties['part']);
|
||||
if (activeAssistantMessageId != null &&
|
||||
part['messageID']?.toString().trim() ==
|
||||
activeAssistantMessageId &&
|
||||
part['type']?.toString().trim() == 'text') {
|
||||
lastAssistantText = part['text']?.toString();
|
||||
if ((lastAssistantText?.trim().isNotEmpty ?? false)) {
|
||||
completeSuccess();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {},
|
||||
onDone: () {},
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
await postJsonInternal(
|
||||
buildRestUriInternal(
|
||||
base,
|
||||
'/session/$remoteSessionId/message',
|
||||
queryParameters: <String, String>{
|
||||
'directory': request.workingDirectory,
|
||||
},
|
||||
),
|
||||
body: <String, dynamic>{
|
||||
'agent': 'build',
|
||||
'parts': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'type': 'text', 'text': request.prompt},
|
||||
],
|
||||
},
|
||||
gatewayToken: request.gatewayToken,
|
||||
);
|
||||
unawaited(
|
||||
pollRestAssistantMessageInternal(
|
||||
base,
|
||||
remoteSessionId: remoteSessionId,
|
||||
workingDirectory: request.workingDirectory,
|
||||
gatewayToken: request.gatewayToken,
|
||||
onResolved: (text) {
|
||||
if (text.trim().isNotEmpty) {
|
||||
lastAssistantText = text;
|
||||
if (output.toString().trim().isEmpty) {
|
||||
output.write(text);
|
||||
request.onOutput?.call(text);
|
||||
}
|
||||
completeSuccess();
|
||||
}
|
||||
},
|
||||
onError: completeFailure,
|
||||
),
|
||||
);
|
||||
|
||||
return await completion.future.timeout(
|
||||
const Duration(minutes: 10),
|
||||
onTimeout: () => DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
errorMessage: 'OpenCode REST request timed out.',
|
||||
aborted: abortedSessionsInternal.contains(normalizedSessionId),
|
||||
resolvedModel: request.model,
|
||||
resolvedWorkingDirectory: request.workingDirectory,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
return DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
errorMessage: error.toString(),
|
||||
aborted: abortedSessionsInternal.contains(normalizedSessionId),
|
||||
resolvedModel: request.model,
|
||||
resolvedWorkingDirectory: request.workingDirectory,
|
||||
);
|
||||
} finally {
|
||||
unawaited(lineSubscription?.cancel());
|
||||
eventClient.close(force: true);
|
||||
abortedSessionsInternal.remove(normalizedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> abort(
|
||||
String sessionId, {
|
||||
required List<Uri> candidateBases,
|
||||
}) async {
|
||||
final normalizedSessionId = sessionId.trim();
|
||||
if (normalizedSessionId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
abortedSessionsInternal.add(normalizedSessionId);
|
||||
final restSessionId =
|
||||
restSessionIdsInternal[normalizedSessionId]?.trim() ?? '';
|
||||
if (restSessionId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final base in candidateBases) {
|
||||
try {
|
||||
await postJsonInternal(
|
||||
buildRestUriInternal(base, '/session/$restSessionId/abort'),
|
||||
body: null,
|
||||
gatewayToken: '',
|
||||
);
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> ensureRestSessionInternal(
|
||||
Uri base, {
|
||||
required String sessionId,
|
||||
required String workingDirectory,
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
final normalizedWorkingDirectory = workingDirectory.trim();
|
||||
final existing = restSessionIdsInternal[sessionId]?.trim() ?? '';
|
||||
if (existing.isNotEmpty) {
|
||||
final existingWorkingDirectory =
|
||||
restSessionWorkingDirectoriesInternal[sessionId]?.trim() ?? '';
|
||||
final canReuseExistingSession =
|
||||
normalizedWorkingDirectory.isEmpty ||
|
||||
(existingWorkingDirectory.isNotEmpty &&
|
||||
existingWorkingDirectory == normalizedWorkingDirectory);
|
||||
if (canReuseExistingSession) {
|
||||
return existing;
|
||||
}
|
||||
restSessionIdsInternal.remove(sessionId);
|
||||
restSessionWorkingDirectoriesInternal.remove(sessionId);
|
||||
}
|
||||
final created = await postJsonInternal(
|
||||
buildRestUriInternal(
|
||||
base,
|
||||
'/session',
|
||||
queryParameters: <String, String>{
|
||||
'directory': normalizedWorkingDirectory,
|
||||
},
|
||||
),
|
||||
body: <String, dynamic>{'title': sessionId},
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
final createdId = created['id']?.toString().trim() ?? '';
|
||||
if (createdId.isEmpty) {
|
||||
throw StateError('OpenCode REST endpoint returned an empty session id.');
|
||||
}
|
||||
restSessionIdsInternal[sessionId] = createdId;
|
||||
if (normalizedWorkingDirectory.isNotEmpty) {
|
||||
restSessionWorkingDirectoriesInternal[sessionId] =
|
||||
normalizedWorkingDirectory;
|
||||
}
|
||||
return createdId;
|
||||
}
|
||||
|
||||
Future<void> pollRestAssistantMessageInternal(
|
||||
Uri base, {
|
||||
required String remoteSessionId,
|
||||
required String workingDirectory,
|
||||
required String gatewayToken,
|
||||
required void Function(String text) onResolved,
|
||||
required void Function(String message) onError,
|
||||
}) async {
|
||||
String? previousText;
|
||||
var stableCount = 0;
|
||||
for (var attempt = 0; attempt < 100; attempt++) {
|
||||
try {
|
||||
final items = await fetchJsonListInternal(
|
||||
buildRestUriInternal(
|
||||
base,
|
||||
'/session/$remoteSessionId/message',
|
||||
queryParameters: <String, String>{
|
||||
'directory': workingDirectory,
|
||||
'limit': '20',
|
||||
},
|
||||
),
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
final text = latestAssistantTextFromRestMessagesInternal(items);
|
||||
if (text.trim().isNotEmpty) {
|
||||
if (text == previousText) {
|
||||
stableCount += 1;
|
||||
} else {
|
||||
previousText = text;
|
||||
stableCount = 1;
|
||||
}
|
||||
if (stableCount >= 2) {
|
||||
onResolved(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
onError(error.toString());
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
onError('OpenCode REST session completed without assistant content.');
|
||||
}
|
||||
|
||||
String latestAssistantTextFromRestMessagesInternal(List<Object?> items) {
|
||||
for (final raw in items.reversed) {
|
||||
final item = asMapInternal(raw);
|
||||
final info = asMapInternal(item['info']);
|
||||
if (info['role']?.toString().trim() != 'assistant') {
|
||||
continue;
|
||||
}
|
||||
final parts = item['parts'];
|
||||
if (parts is! List) {
|
||||
continue;
|
||||
}
|
||||
for (final rawPart in parts) {
|
||||
final part = asMapInternal(rawPart);
|
||||
if (part['type']?.toString().trim() == 'text') {
|
||||
final text = part['text']?.toString() ?? '';
|
||||
if (text.trim().isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
class DirectAppServerConnectionInternal {
|
||||
DirectAppServerConnectionInternal(this.socketInternal);
|
||||
|
||||
final WebSocket socketInternal;
|
||||
final StreamController<Map<String, dynamic>> notificationsInternal =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final Map<String, Completer<Map<String, dynamic>>> pendingRequestsInternal =
|
||||
<String, Completer<Map<String, dynamic>>>{};
|
||||
int requestCounterInternal = 0;
|
||||
bool initializedInternal = false;
|
||||
StreamSubscription<dynamic>? subscriptionInternal;
|
||||
|
||||
Stream<Map<String, dynamic>> get notifications =>
|
||||
notificationsInternal.stream;
|
||||
|
||||
static Future<DirectAppServerConnectionInternal> 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 = DirectAppServerConnectionInternal(socket);
|
||||
connection.attachInternal();
|
||||
return connection;
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (initializedInternal) {
|
||||
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>{});
|
||||
initializedInternal = 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}-${requestCounterInternal++}';
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
pendingRequestsInternal[id] = completer;
|
||||
socketInternal.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'method': method,
|
||||
'params': params,
|
||||
}),
|
||||
);
|
||||
return completer.future.timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
pendingRequestsInternal.remove(id);
|
||||
throw TimeoutException(
|
||||
'Single-agent app-server request $method timed out.',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> notify(
|
||||
String method, {
|
||||
required Map<String, dynamic> params,
|
||||
}) async {
|
||||
socketInternal.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'method': method,
|
||||
'params': params,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void attachInternal() {
|
||||
subscriptionInternal = socketInternal.listen(
|
||||
(dynamic raw) {
|
||||
final message = decodeMapInternal(raw);
|
||||
final id = message['id']?.toString();
|
||||
if (id != null && message.containsKey('result')) {
|
||||
final completer = pendingRequestsInternal.remove(id);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete(asMapInternal(message['result']));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id != null && message.containsKey('error')) {
|
||||
final completer = pendingRequestsInternal.remove(id);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
final error = asMapInternal(message['error']);
|
||||
completer.completeError(
|
||||
StateError(
|
||||
error['message']?.toString() ??
|
||||
'Single-agent app-server request failed.',
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.containsKey('method')) {
|
||||
notificationsInternal.add(message);
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
for (final completer in pendingRequestsInternal.values) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
}
|
||||
pendingRequestsInternal.clear();
|
||||
notificationsInternal.addError(error, stackTrace);
|
||||
},
|
||||
onDone: () {
|
||||
final error = StateError(
|
||||
'Single-agent app-server websocket closed unexpectedly.',
|
||||
);
|
||||
for (final completer in pendingRequestsInternal.values) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
}
|
||||
pendingRequestsInternal.clear();
|
||||
if (!notificationsInternal.isClosed) {
|
||||
unawaited(notificationsInternal.close());
|
||||
}
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await subscriptionInternal?.cancel();
|
||||
subscriptionInternal = null;
|
||||
for (final completer in pendingRequestsInternal.values) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(
|
||||
StateError('Single-agent app-server connection closed.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
pendingRequestsInternal.clear();
|
||||
if (!notificationsInternal.isClosed) {
|
||||
await notificationsInternal.close();
|
||||
}
|
||||
try {
|
||||
await socketInternal.close();
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,227 +1,4 @@
|
||||
import 'direct_single_agent_app_server_client.dart';
|
||||
import 'runtime_models.dart';
|
||||
|
||||
class SingleAgentProviderResolution {
|
||||
const SingleAgentProviderResolution({
|
||||
required this.selection,
|
||||
required this.resolvedProvider,
|
||||
required this.fallbackReason,
|
||||
});
|
||||
|
||||
final SingleAgentProvider selection;
|
||||
final SingleAgentProvider? resolvedProvider;
|
||||
final String? fallbackReason;
|
||||
}
|
||||
|
||||
class SingleAgentRunRequest {
|
||||
const SingleAgentRunRequest({
|
||||
required this.sessionId,
|
||||
required this.provider,
|
||||
required this.prompt,
|
||||
required this.model,
|
||||
required this.workingDirectory,
|
||||
required this.gatewayToken,
|
||||
required this.attachments,
|
||||
required this.selectedSkills,
|
||||
required this.aiGatewayBaseUrl,
|
||||
required this.aiGatewayApiKey,
|
||||
required this.config,
|
||||
this.onOutput,
|
||||
this.configuredCodexCliPath = '',
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final SingleAgentProvider provider;
|
||||
final String prompt;
|
||||
final String model;
|
||||
final String workingDirectory;
|
||||
final String gatewayToken;
|
||||
final List<CollaborationAttachment> attachments;
|
||||
final List<AssistantThreadSkillEntry> selectedSkills;
|
||||
final String aiGatewayBaseUrl;
|
||||
final String aiGatewayApiKey;
|
||||
final MultiAgentConfig config;
|
||||
final void Function(String text)? onOutput;
|
||||
final String configuredCodexCliPath;
|
||||
}
|
||||
|
||||
class SingleAgentRunResult {
|
||||
const SingleAgentRunResult({
|
||||
required this.provider,
|
||||
required this.output,
|
||||
required this.success,
|
||||
required this.errorMessage,
|
||||
required this.shouldFallbackToAiChat,
|
||||
this.aborted = false,
|
||||
this.fallbackReason,
|
||||
this.resolvedModel = '',
|
||||
this.resolvedWorkingDirectory = '',
|
||||
});
|
||||
|
||||
final SingleAgentProvider provider;
|
||||
final String output;
|
||||
final bool success;
|
||||
final String errorMessage;
|
||||
final bool shouldFallbackToAiChat;
|
||||
final bool aborted;
|
||||
final String? fallbackReason;
|
||||
final String resolvedModel;
|
||||
final String resolvedWorkingDirectory;
|
||||
}
|
||||
|
||||
abstract class SingleAgentRunner {
|
||||
Future<SingleAgentProviderResolution> resolveProvider({
|
||||
required SingleAgentProvider selection,
|
||||
required List<SingleAgentProvider> availableProviders,
|
||||
required String configuredCodexCliPath,
|
||||
required String gatewayToken,
|
||||
});
|
||||
|
||||
Future<SingleAgentRunResult> run(SingleAgentRunRequest request);
|
||||
|
||||
Future<void> abort(String sessionId);
|
||||
}
|
||||
|
||||
class DefaultSingleAgentRunner implements SingleAgentRunner {
|
||||
DefaultSingleAgentRunner({
|
||||
required DirectSingleAgentAppServerClient appServerClient,
|
||||
}) : _appServerClient = appServerClient;
|
||||
|
||||
final DirectSingleAgentAppServerClient _appServerClient;
|
||||
|
||||
@override
|
||||
Future<SingleAgentProviderResolution> resolveProvider({
|
||||
required SingleAgentProvider selection,
|
||||
required List<SingleAgentProvider> availableProviders,
|
||||
required String configuredCodexCliPath,
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
try {
|
||||
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: selection,
|
||||
fallbackReason: null,
|
||||
);
|
||||
}
|
||||
|
||||
String? fallbackReason;
|
||||
for (final provider in availableProviders) {
|
||||
if (provider == SingleAgentProvider.auto) {
|
||||
continue;
|
||||
}
|
||||
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: null,
|
||||
fallbackReason:
|
||||
fallbackReason ??
|
||||
'No external ACP endpoint is currently available.',
|
||||
);
|
||||
} catch (error) {
|
||||
return SingleAgentProviderResolution(
|
||||
selection: selection,
|
||||
resolvedProvider: null,
|
||||
fallbackReason: 'Single-agent app-server negotiation failed: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SingleAgentRunResult> run(SingleAgentRunRequest request) async {
|
||||
try {
|
||||
final result = await _appServerClient.run(
|
||||
DirectSingleAgentRunRequest(
|
||||
sessionId: request.sessionId,
|
||||
provider: request.provider,
|
||||
prompt: _augmentPrompt(request),
|
||||
model: request.model,
|
||||
workingDirectory: request.workingDirectory,
|
||||
gatewayToken: request.gatewayToken,
|
||||
selectedSkills: request.selectedSkills,
|
||||
onOutput: request.onOutput,
|
||||
),
|
||||
);
|
||||
return SingleAgentRunResult(
|
||||
provider: request.provider,
|
||||
output: result.output,
|
||||
success: result.success,
|
||||
errorMessage: result.errorMessage,
|
||||
shouldFallbackToAiChat: !result.success && result.output.isEmpty,
|
||||
aborted: result.aborted,
|
||||
resolvedModel: result.resolvedModel,
|
||||
resolvedWorkingDirectory: result.resolvedWorkingDirectory,
|
||||
fallbackReason: !result.success
|
||||
? 'Single-agent app-server run failed: ${result.errorMessage}'
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
final shouldFallback = _shouldFallbackToAiChat(error.toString());
|
||||
return SingleAgentRunResult(
|
||||
provider: request.provider,
|
||||
output: '',
|
||||
success: false,
|
||||
errorMessage: error.toString(),
|
||||
shouldFallbackToAiChat: shouldFallback,
|
||||
resolvedModel: '',
|
||||
fallbackReason: shouldFallback
|
||||
? '${request.provider.label} provider is unavailable from the direct app-server endpoint.'
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> abort(String sessionId) async {
|
||||
final normalized = sessionId.trim();
|
||||
if (normalized.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _appServerClient.abort(normalized);
|
||||
}
|
||||
|
||||
bool _shouldFallbackToAiChat(String message) {
|
||||
final normalizedMessage = message.toLowerCase();
|
||||
return normalizedMessage.contains('timeout') ||
|
||||
normalizedMessage.contains('unavailable') ||
|
||||
normalizedMessage.contains('missing') ||
|
||||
normalizedMessage.contains('closed') ||
|
||||
normalizedMessage.contains('connect');
|
||||
}
|
||||
|
||||
String _augmentPrompt(SingleAgentRunRequest request) {
|
||||
if (request.attachments.isEmpty) {
|
||||
return request.prompt;
|
||||
}
|
||||
final attachmentLines = request.attachments
|
||||
.map((item) => '- ${item.name}: ${item.path}')
|
||||
.join('\n');
|
||||
return 'User-selected local attachments:\n$attachmentLines\n\n${request.prompt}';
|
||||
}
|
||||
}
|
||||
// Legacy compatibility shim retained until remaining imports are cleaned up.
|
||||
//
|
||||
// Single-agent execution now flows through GoAgentCoreClient and the ACP
|
||||
// transport; the previous direct runner no longer owns runtime strategy.
|
||||
|
||||
@ -15,7 +15,6 @@ import 'package:xworkmate/runtime/gateway_runtime.dart';
|
||||
import 'package:xworkmate/runtime/runtime_coordinator.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/runtime/single_agent_runner.dart';
|
||||
import 'app_controller_ai_gateway_chat_suite_core.dart';
|
||||
import 'app_controller_ai_gateway_chat_suite_chat.dart';
|
||||
import 'app_controller_ai_gateway_chat_suite_single_agent.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import 'package:xworkmate/runtime/gateway_runtime.dart';
|
||||
import 'package:xworkmate/runtime/runtime_coordinator.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/runtime/single_agent_runner.dart';
|
||||
import 'app_controller_ai_gateway_chat_suite_chat.dart';
|
||||
import 'app_controller_ai_gateway_chat_suite_single_agent.dart';
|
||||
import 'app_controller_ai_gateway_chat_suite_fakes.dart';
|
||||
|
||||
@ -13,7 +13,6 @@ import 'package:xworkmate/runtime/go_agent_core_client.dart';
|
||||
import 'package:xworkmate/runtime/runtime_coordinator.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/runtime/single_agent_runner.dart';
|
||||
import 'app_controller_ai_gateway_chat_suite_core.dart';
|
||||
import 'app_controller_ai_gateway_chat_suite_chat.dart';
|
||||
import 'app_controller_ai_gateway_chat_suite_single_agent.dart';
|
||||
@ -113,71 +112,6 @@ class FakeCodexRuntimeInternal extends CodexRuntime {
|
||||
Future<void> stop() async {}
|
||||
}
|
||||
|
||||
class FakeSingleAgentRunnerInternal implements SingleAgentRunner {
|
||||
FakeSingleAgentRunnerInternal({
|
||||
required this.resolvedProvider,
|
||||
this.result,
|
||||
this.fallbackReason,
|
||||
});
|
||||
|
||||
final SingleAgentProvider? resolvedProvider;
|
||||
final SingleAgentRunResult? result;
|
||||
final String? fallbackReason;
|
||||
|
||||
int resolveCalls = 0;
|
||||
int runCalls = 0;
|
||||
int abortCalls = 0;
|
||||
SingleAgentRunRequest? lastRequest;
|
||||
final List<SingleAgentRunRequest> requests = <SingleAgentRunRequest>[];
|
||||
|
||||
@override
|
||||
Future<SingleAgentProviderResolution> resolveProvider({
|
||||
required SingleAgentProvider selection,
|
||||
required List<SingleAgentProvider> availableProviders,
|
||||
required String configuredCodexCliPath,
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
resolveCalls += 1;
|
||||
return SingleAgentProviderResolution(
|
||||
selection: selection,
|
||||
resolvedProvider: resolvedProvider,
|
||||
fallbackReason: fallbackReason,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SingleAgentRunResult> run(SingleAgentRunRequest request) async {
|
||||
runCalls += 1;
|
||||
lastRequest = request;
|
||||
requests.add(request);
|
||||
if (result?.output.isNotEmpty == true) {
|
||||
request.onOutput?.call(result!.output);
|
||||
}
|
||||
return result ??
|
||||
SingleAgentRunResult(
|
||||
provider: request.provider,
|
||||
output: '',
|
||||
success: false,
|
||||
errorMessage: 'no result configured',
|
||||
shouldFallbackToAiChat: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> abort(String sessionId) async {
|
||||
abortCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class FallbackOnlySingleAgentRunnerInternal
|
||||
extends FakeSingleAgentRunnerInternal {
|
||||
FallbackOnlySingleAgentRunnerInternal()
|
||||
: super(
|
||||
resolvedProvider: null,
|
||||
fallbackReason: 'No supported external CLI provider is available.',
|
||||
);
|
||||
}
|
||||
|
||||
class FakeGoAgentCoreClientInternal implements GoAgentCoreClient {
|
||||
FakeGoAgentCoreClientInternal({
|
||||
this.capabilities = const GoAgentCoreCapabilities.empty(),
|
||||
@ -198,7 +132,8 @@ class FakeGoAgentCoreClientInternal implements GoAgentCoreClient {
|
||||
int executeCalls = 0;
|
||||
int cancelCalls = 0;
|
||||
GoAgentCoreSessionRequest? lastRequest;
|
||||
final List<GoAgentCoreSessionRequest> requests = <GoAgentCoreSessionRequest>[];
|
||||
final List<GoAgentCoreSessionRequest> requests =
|
||||
<GoAgentCoreSessionRequest>[];
|
||||
|
||||
@override
|
||||
Future<GoAgentCoreCapabilities> loadCapabilities({
|
||||
|
||||
@ -1,937 +0,0 @@
|
||||
@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';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
void main() {
|
||||
group('DirectSingleAgentAppServerClient', () {
|
||||
test(
|
||||
'direct single-agent app-server core file stays split into focused parts',
|
||||
() {
|
||||
final lines = File(
|
||||
'lib/runtime/direct_single_agent_app_server_client_core.dart',
|
||||
).readAsLinesSync();
|
||||
|
||||
expect(
|
||||
lines.length,
|
||||
lessThanOrEqualTo(1000),
|
||||
reason: 'The core file should stay under the target line budget.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('classifies the four endpoint modes', () {
|
||||
expect(
|
||||
DirectSingleAgentEndpointDescriptor.describe(
|
||||
Uri.parse('ws://127.0.0.1:9001'),
|
||||
).mode,
|
||||
DirectSingleAgentEndpointMode.wsLocal,
|
||||
);
|
||||
expect(
|
||||
DirectSingleAgentEndpointDescriptor.describe(
|
||||
Uri.parse('wss://agent.example.com'),
|
||||
).mode,
|
||||
DirectSingleAgentEndpointMode.wss,
|
||||
);
|
||||
expect(
|
||||
DirectSingleAgentEndpointDescriptor.describe(
|
||||
Uri.parse('http://localhost:38992'),
|
||||
).mode,
|
||||
DirectSingleAgentEndpointMode.httpLocal,
|
||||
);
|
||||
expect(
|
||||
DirectSingleAgentEndpointDescriptor.describe(
|
||||
Uri.parse('https://agent.example.com'),
|
||||
).mode,
|
||||
DirectSingleAgentEndpointMode.https,
|
||||
);
|
||||
});
|
||||
|
||||
test('probes websocket endpoint and reports provider support', () async {
|
||||
final server = await FakeAppServerInternal.start();
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
|
||||
final capabilities = await client.loadCapabilities(
|
||||
provider: SingleAgentProvider.opencode,
|
||||
);
|
||||
|
||||
expect(capabilities.available, isTrue);
|
||||
expect(
|
||||
capabilities.supportsProvider(SingleAgentProvider.opencode),
|
||||
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 FakeAppServerInternal.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',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
prompt: 'hello world',
|
||||
model: 'gpt-4.1',
|
||||
workingDirectory: '/tmp',
|
||||
gatewayToken: 'token-1',
|
||||
).copyWith(onOutput: deltas.add),
|
||||
);
|
||||
|
||||
expect(result.success, isTrue, reason: result.errorMessage);
|
||||
expect(result.output, 'hello world from app server');
|
||||
expect(result.resolvedModel, 'codex-sonnet');
|
||||
expect(result.resolvedWorkingDirectory, '/tmp');
|
||||
expect(result.resolvedWorkspaceRefKind, WorkspaceRefKind.localPath);
|
||||
expect(server.lastTurnInput, <Object?>[
|
||||
<String, dynamic>{'type': 'text', 'text': 'hello world'},
|
||||
]);
|
||||
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(
|
||||
'starts a new websocket thread when working directory changes for a session',
|
||||
() async {
|
||||
final server = await FakeAppServerInternal.start();
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
final first = await client.run(
|
||||
const DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-cwd-change',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
prompt: 'first turn',
|
||||
model: 'gpt-4.1',
|
||||
workingDirectory: '/tmp/a',
|
||||
gatewayToken: '',
|
||||
),
|
||||
);
|
||||
final second = await client.run(
|
||||
const DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-cwd-change',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
prompt: 'second turn',
|
||||
model: 'gpt-4.1',
|
||||
workingDirectory: '/tmp/b',
|
||||
gatewayToken: '',
|
||||
),
|
||||
);
|
||||
|
||||
expect(first.success, isTrue, reason: first.errorMessage);
|
||||
expect(second.success, isTrue, reason: second.errorMessage);
|
||||
expect(second.resolvedWorkingDirectory, '/tmp/b');
|
||||
expect(
|
||||
server.methods.where((method) => method == 'thread/start').length,
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('sends selected skills as structured app-server inputs', () async {
|
||||
final server = await FakeAppServerInternal.start();
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
final result = await client.run(
|
||||
DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-skills',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
prompt: 'use the selected skills',
|
||||
model: 'gpt-4.1',
|
||||
workingDirectory: '/tmp',
|
||||
gatewayToken: '',
|
||||
selectedSkills: const <AssistantThreadSkillEntry>[
|
||||
AssistantThreadSkillEntry(
|
||||
key: '/tmp/ppt',
|
||||
label: 'PPT',
|
||||
description: 'Slides',
|
||||
source: 'codex',
|
||||
sourcePath: '/tmp/ppt/SKILL.md',
|
||||
scope: 'user',
|
||||
sourceLabel: 'codex · user · ppt',
|
||||
),
|
||||
AssistantThreadSkillEntry(
|
||||
key: '/tmp/browser',
|
||||
label: 'Browser Automation',
|
||||
description: 'Browser',
|
||||
source: 'agents',
|
||||
sourcePath: '/tmp/browser/SKILL.md',
|
||||
scope: 'user',
|
||||
sourceLabel: 'agents · user · browser',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(server.lastTurnInput, <Object?>[
|
||||
<String, dynamic>{'type': 'text', 'text': 'use the selected skills'},
|
||||
<String, dynamic>{
|
||||
'type': 'skill',
|
||||
'name': 'PPT',
|
||||
'path': '/tmp/ppt/SKILL.md',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'type': 'skill',
|
||||
'name': 'Browser Automation',
|
||||
'path': '/tmp/browser/SKILL.md',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('interrupts active turns on abort', () async {
|
||||
final server = await FakeAppServerInternal.start(delayCompletion: true);
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
final runFuture = client.run(
|
||||
const DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-abort',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
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'));
|
||||
});
|
||||
|
||||
test(
|
||||
'accepts nested thread objects returned by codex app-server',
|
||||
() async {
|
||||
final server = await FakeAppServerInternal.start(
|
||||
nestedThreadResult: true,
|
||||
);
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
final result = await client.run(
|
||||
const DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-nested',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
prompt: 'hello nested world',
|
||||
model: 'qwen2.5-coder:latest',
|
||||
workingDirectory: '/tmp',
|
||||
gatewayToken: '',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.output, 'hello world from app server');
|
||||
expect(result.resolvedModel, 'codex-sonnet');
|
||||
expect(result.resolvedWorkingDirectory, '/tmp');
|
||||
expect(result.resolvedWorkspaceRefKind, WorkspaceRefKind.localPath);
|
||||
},
|
||||
);
|
||||
|
||||
test('captures the resolved thread path returned by app-server', () async {
|
||||
final server = await FakeAppServerInternal.start(
|
||||
resolvedThreadPath: '/tmp/app-server-thread',
|
||||
);
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
final result = await client.run(
|
||||
const DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-thread-path',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
prompt: 'hello thread path',
|
||||
model: 'gpt-4.1',
|
||||
workingDirectory: '/tmp/requested-thread',
|
||||
gatewayToken: '',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.resolvedWorkingDirectory, '/tmp/app-server-thread');
|
||||
expect(result.resolvedWorkspaceRefKind, WorkspaceRefKind.localPath);
|
||||
});
|
||||
|
||||
test(
|
||||
'probes OpenCode REST endpoint and reports provider support',
|
||||
() async {
|
||||
final server = await FakeOpenCodeRestServerInternal.start();
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
|
||||
final capabilities = await client.loadCapabilities(
|
||||
provider: SingleAgentProvider.opencode,
|
||||
);
|
||||
|
||||
expect(capabilities.available, isTrue);
|
||||
expect(
|
||||
capabilities.supportsProvider(SingleAgentProvider.opencode),
|
||||
isTrue,
|
||||
);
|
||||
expect(server.healthRequested, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('runs OpenCode turns over REST session api', () async {
|
||||
final server = await FakeOpenCodeRestServerInternal.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-opencode',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
prompt: 'hello opencode',
|
||||
model: '',
|
||||
workingDirectory: '/tmp',
|
||||
gatewayToken: '',
|
||||
).copyWith(onOutput: deltas.add),
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.output, 'hello world from opencode');
|
||||
expect(deltas.join(), 'hello world from opencode');
|
||||
expect(server.createdSessionCount, 1);
|
||||
expect(server.lastPromptText, 'hello opencode');
|
||||
});
|
||||
|
||||
test(
|
||||
'creates a new REST session when working directory changes for a session',
|
||||
() async {
|
||||
final server = await FakeOpenCodeRestServerInternal.start();
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
final first = await client.run(
|
||||
const DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-opencode-cwd-change',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
prompt: 'first',
|
||||
model: '',
|
||||
workingDirectory: '/tmp/a',
|
||||
gatewayToken: '',
|
||||
),
|
||||
);
|
||||
final second = await client.run(
|
||||
const DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-opencode-cwd-change',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
prompt: 'second',
|
||||
model: '',
|
||||
workingDirectory: '/tmp/b',
|
||||
gatewayToken: '',
|
||||
),
|
||||
);
|
||||
|
||||
expect(first.success, isTrue, reason: first.errorMessage);
|
||||
expect(second.success, isTrue, reason: second.errorMessage);
|
||||
expect(server.createdSessionCount, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'fails OpenCode REST turns that complete without assistant content',
|
||||
() async {
|
||||
final server = await FakeOpenCodeRestServerInternal.start(
|
||||
emitAssistantContent: false,
|
||||
);
|
||||
addTearDown(server.close);
|
||||
|
||||
final client = DirectSingleAgentAppServerClient(
|
||||
endpointResolver: (_) => server.baseHttpUri,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
final result = await client.run(
|
||||
const DirectSingleAgentRunRequest(
|
||||
sessionId: 'session-opencode-empty',
|
||||
provider: SingleAgentProvider.opencode,
|
||||
prompt: 'hello opencode',
|
||||
model: '',
|
||||
workingDirectory: '/tmp',
|
||||
gatewayToken: '',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.output, isEmpty);
|
||||
expect(result.errorMessage, contains('without assistant content'));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class FakeAppServerInternal {
|
||||
FakeAppServerInternal._(
|
||||
this.serverInternal, {
|
||||
required this.delayCompletion,
|
||||
required this.nestedThreadResult,
|
||||
required this.resolvedThreadPath,
|
||||
});
|
||||
|
||||
final HttpServer serverInternal;
|
||||
final bool delayCompletion;
|
||||
final bool nestedThreadResult;
|
||||
final String? resolvedThreadPath;
|
||||
final List<String> methods = <String>[];
|
||||
final List<String> authorizationHeaders = <String>[];
|
||||
final Map<String, Completer<void>> methodWaitersInternal =
|
||||
<String, Completer<void>>{};
|
||||
int threadCounterInternal = 0;
|
||||
List<Object?>? lastTurnInput;
|
||||
|
||||
int get port => serverInternal.port;
|
||||
Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${serverInternal.port}');
|
||||
|
||||
static Future<FakeAppServerInternal> start({
|
||||
bool delayCompletion = false,
|
||||
bool nestedThreadResult = false,
|
||||
String? resolvedThreadPath,
|
||||
}) async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final fake = FakeAppServerInternal._(
|
||||
server,
|
||||
delayCompletion: delayCompletion,
|
||||
nestedThreadResult: nestedThreadResult,
|
||||
resolvedThreadPath: resolvedThreadPath,
|
||||
);
|
||||
unawaited(fake.listenInternal());
|
||||
return fake;
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await serverInternal.close(force: true);
|
||||
}
|
||||
|
||||
Future<void> waitForMethod(String method) async {
|
||||
if (methods.contains(method)) {
|
||||
return;
|
||||
}
|
||||
final completer = methodWaitersInternal.putIfAbsent(
|
||||
method,
|
||||
Completer<void>.new,
|
||||
);
|
||||
await completer.future.timeout(const Duration(seconds: 3));
|
||||
}
|
||||
|
||||
Future<void> listenInternal() async {
|
||||
await for (final request in serverInternal) {
|
||||
authorizationHeaders.add(
|
||||
request.headers.value(HttpHeaders.authorizationHeader) ?? '',
|
||||
);
|
||||
if (request.uri.path == '/' &&
|
||||
WebSocketTransformer.isUpgradeRequest(request)) {
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
unawaited(handleSocketInternal(socket));
|
||||
continue;
|
||||
}
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleSocketInternal(WebSocket socket) async {
|
||||
await for (final raw in socket) {
|
||||
final message = decodeMapInternal(raw);
|
||||
final method = message['method']?.toString() ?? '';
|
||||
final id = message['id'];
|
||||
final params = asMapInternal(message['params']);
|
||||
if (method.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
methods.add(method);
|
||||
methodWaitersInternal.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':
|
||||
threadCounterInternal += 1;
|
||||
final threadPath = resolvedThreadPath ?? params['cwd'] ?? '/tmp';
|
||||
final result = nestedThreadResult
|
||||
? <String, dynamic>{
|
||||
'thread': <String, dynamic>{
|
||||
'id': 'thread-$threadCounterInternal',
|
||||
'path': threadPath,
|
||||
'ephemeral': false,
|
||||
},
|
||||
}
|
||||
: <String, dynamic>{
|
||||
'id': 'thread-$threadCounterInternal',
|
||||
'path': threadPath,
|
||||
'ephemeral': false,
|
||||
};
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'result': result,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'thread/resume':
|
||||
final threadPath = resolvedThreadPath ?? params['cwd'] ?? '/tmp';
|
||||
final result = nestedThreadResult
|
||||
? <String, dynamic>{
|
||||
'thread': <String, dynamic>{
|
||||
'id': params['threadId'] ?? 'thread-resumed',
|
||||
'path': threadPath,
|
||||
'ephemeral': false,
|
||||
},
|
||||
}
|
||||
: <String, dynamic>{
|
||||
'id': params['threadId'] ?? 'thread-resumed',
|
||||
'path': threadPath,
|
||||
'ephemeral': false,
|
||||
};
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'result': result,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'turn/start':
|
||||
final threadId = params['threadId']?.toString() ?? 'thread-1';
|
||||
if (params.containsKey('userInput')) {
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'error': <String, dynamic>{
|
||||
'code': -32600,
|
||||
'message': 'Invalid request: missing field `input`',
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
final input = params['input'];
|
||||
if (input is! List) {
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'error': <String, dynamic>{
|
||||
'code': -32600,
|
||||
'message':
|
||||
'Invalid request: invalid type: expected a sequence',
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
lastTurnInput = List<Object?>.from(input);
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'result': <String, dynamic>{
|
||||
'id': 'turn-1',
|
||||
'threadId': threadId,
|
||||
'status': 'started',
|
||||
'model': 'codex-sonnet',
|
||||
},
|
||||
}),
|
||||
);
|
||||
unawaited(emitTurnInternal(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> emitTurnInternal(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'},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeOpenCodeRestServerInternal {
|
||||
FakeOpenCodeRestServerInternal._(
|
||||
this.serverInternal, {
|
||||
required this.emitAssistantContent,
|
||||
});
|
||||
|
||||
final HttpServer serverInternal;
|
||||
final bool emitAssistantContent;
|
||||
final List<HttpResponse> eventResponsesInternal = <HttpResponse>[];
|
||||
var sessionCounterInternal = 0;
|
||||
var messageCounterInternal = 0;
|
||||
bool healthRequested = false;
|
||||
int createdSessionCount = 0;
|
||||
String lastPromptText = '';
|
||||
final Map<String, String> assistantTextBySessionInternal = <String, String>{};
|
||||
|
||||
Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${serverInternal.port}');
|
||||
|
||||
static Future<FakeOpenCodeRestServerInternal> start({
|
||||
bool emitAssistantContent = true,
|
||||
}) async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final fake = FakeOpenCodeRestServerInternal._(
|
||||
server,
|
||||
emitAssistantContent: emitAssistantContent,
|
||||
);
|
||||
unawaited(fake.listenInternal());
|
||||
return fake;
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
for (final response in eventResponsesInternal.toList(growable: false)) {
|
||||
try {
|
||||
await response.close();
|
||||
} catch (_) {
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
await serverInternal.close(force: true);
|
||||
}
|
||||
|
||||
Future<void> listenInternal() async {
|
||||
await for (final request in serverInternal) {
|
||||
if (request.uri.path == '/global/health') {
|
||||
healthRequested = true;
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write(
|
||||
jsonEncode(<String, dynamic>{'healthy': true, 'version': '1.3.3'}),
|
||||
);
|
||||
await request.response.close();
|
||||
continue;
|
||||
}
|
||||
if (request.uri.path == '/global/event') {
|
||||
request.response.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
'text/event-stream',
|
||||
);
|
||||
request.response.headers.set(
|
||||
HttpHeaders.cacheControlHeader,
|
||||
'no-cache',
|
||||
);
|
||||
request.response.write(
|
||||
'data: ${jsonEncode(<String, dynamic>{
|
||||
'payload': <String, dynamic>{'type': 'server.connected', 'properties': <String, dynamic>{}},
|
||||
})}\n\n',
|
||||
);
|
||||
await request.response.flush();
|
||||
eventResponsesInternal.add(request.response);
|
||||
continue;
|
||||
}
|
||||
if (request.uri.path == '/session' && request.method == 'POST') {
|
||||
createdSessionCount += 1;
|
||||
final sessionId = 'ses-${sessionCounterInternal++}';
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'id': sessionId,
|
||||
'title': 'test',
|
||||
'directory':
|
||||
request.uri.queryParameters['directory'] ??
|
||||
Directory.current.path,
|
||||
}),
|
||||
);
|
||||
await request.response.close();
|
||||
continue;
|
||||
}
|
||||
final sessionMatch = RegExp(
|
||||
r'^/session/([^/]+)/message$',
|
||||
).firstMatch(request.uri.path);
|
||||
if (sessionMatch != null && request.method == 'GET') {
|
||||
final sessionId = sessionMatch.group(1)!;
|
||||
final text = assistantTextBySessionInternal[sessionId] ?? '';
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write(
|
||||
jsonEncode(<Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'info': <String, dynamic>{'id': 'msg-user', 'role': 'user'},
|
||||
'parts': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'type': 'text', 'text': lastPromptText},
|
||||
],
|
||||
},
|
||||
if (text.isNotEmpty)
|
||||
<String, dynamic>{
|
||||
'info': <String, dynamic>{
|
||||
'id': 'msg-assistant',
|
||||
'role': 'assistant',
|
||||
},
|
||||
'parts': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'type': 'text', 'text': text},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
await request.response.close();
|
||||
continue;
|
||||
}
|
||||
if (sessionMatch != null && request.method == 'POST') {
|
||||
final sessionId = sessionMatch.group(1)!;
|
||||
final body = jsonDecode(await utf8.decodeStream(request));
|
||||
final parts =
|
||||
(body as Map<String, dynamic>)['parts'] as List<dynamic>? ??
|
||||
const <dynamic>[];
|
||||
if (parts.isNotEmpty) {
|
||||
lastPromptText =
|
||||
(parts.first as Map<String, dynamic>)['text']?.toString() ?? '';
|
||||
}
|
||||
final assistantMessageId = 'msg-assistant-${messageCounterInternal++}';
|
||||
await broadcastEventInternal(<String, dynamic>{
|
||||
'payload': <String, dynamic>{
|
||||
'type': 'session.status',
|
||||
'properties': <String, dynamic>{
|
||||
'sessionID': sessionId,
|
||||
'status': <String, dynamic>{'type': 'busy'},
|
||||
},
|
||||
},
|
||||
});
|
||||
await broadcastEventInternal(<String, dynamic>{
|
||||
'payload': <String, dynamic>{
|
||||
'type': 'message.updated',
|
||||
'properties': <String, dynamic>{
|
||||
'sessionID': sessionId,
|
||||
'info': <String, dynamic>{
|
||||
'id': assistantMessageId,
|
||||
'role': 'assistant',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (emitAssistantContent) {
|
||||
for (final delta in <String>[
|
||||
'hello ',
|
||||
'world ',
|
||||
'from ',
|
||||
'opencode',
|
||||
]) {
|
||||
await broadcastEventInternal(<String, dynamic>{
|
||||
'payload': <String, dynamic>{
|
||||
'type': 'message.part.delta',
|
||||
'properties': <String, dynamic>{
|
||||
'sessionID': sessionId,
|
||||
'part': <String, dynamic>{'messageID': assistantMessageId},
|
||||
'text': delta,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
await broadcastEventInternal(<String, dynamic>{
|
||||
'payload': <String, dynamic>{
|
||||
'type': 'message.part.updated',
|
||||
'properties': <String, dynamic>{
|
||||
'sessionID': sessionId,
|
||||
'part': <String, dynamic>{
|
||||
'messageID': assistantMessageId,
|
||||
'type': 'text',
|
||||
'text': 'hello world from opencode',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assistantTextBySessionInternal[sessionId] =
|
||||
'hello world from opencode';
|
||||
}
|
||||
await broadcastEventInternal(<String, dynamic>{
|
||||
'payload': <String, dynamic>{
|
||||
'type': 'session.status',
|
||||
'properties': <String, dynamic>{
|
||||
'sessionID': sessionId,
|
||||
'status': <String, dynamic>{'type': 'idle'},
|
||||
},
|
||||
},
|
||||
});
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write('');
|
||||
await request.response.close();
|
||||
continue;
|
||||
}
|
||||
final abortMatch = RegExp(
|
||||
r'^/session/([^/]+)/abort$',
|
||||
).firstMatch(request.uri.path);
|
||||
if (abortMatch != null && request.method == 'POST') {
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write('{}');
|
||||
await request.response.close();
|
||||
continue;
|
||||
}
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> broadcastEventInternal(Map<String, dynamic> event) async {
|
||||
final payload = 'data: ${jsonEncode(event)}\n\n';
|
||||
for (final response in eventResponsesInternal.toList(growable: false)) {
|
||||
response.write(payload);
|
||||
await response.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> decodeMapInternal(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> asMapInternal(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,
|
||||
List<AssistantThreadSkillEntry>? selectedSkills,
|
||||
}) {
|
||||
return DirectSingleAgentRunRequest(
|
||||
sessionId: sessionId,
|
||||
provider: provider,
|
||||
prompt: prompt,
|
||||
model: model,
|
||||
workingDirectory: workingDirectory,
|
||||
gatewayToken: gatewayToken,
|
||||
selectedSkills: selectedSkills ?? this.selectedSkills,
|
||||
onOutput: onOutput ?? this.onOutput,
|
||||
);
|
||||
}
|
||||
}
|
||||
123
test/runtime/go_agent_core_client_suite.dart
Normal file
123
test/runtime/go_agent_core_client_suite.dart
Normal file
@ -0,0 +1,123 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/go_agent_core_client.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
void main() {
|
||||
group('GoAgentCore client mapping', () {
|
||||
test('session request maps skills, attachments, and provider into ACP', () {
|
||||
const request = GoAgentCoreSessionRequest(
|
||||
sessionId: 'session-1',
|
||||
threadId: 'thread-1',
|
||||
target: AssistantExecutionTarget.singleAgent,
|
||||
prompt: 'hello world',
|
||||
workingDirectory: '/tmp/workspace',
|
||||
model: 'codex-sonnet',
|
||||
thinking: 'medium',
|
||||
selectedSkills: <String>['PPT', 'Browser Automation'],
|
||||
inlineAttachments: <GatewayChatAttachmentPayload>[
|
||||
GatewayChatAttachmentPayload(
|
||||
type: 'inline',
|
||||
fileName: 'note.txt',
|
||||
mimeType: 'text/plain',
|
||||
content: 'aGVsbG8=',
|
||||
),
|
||||
],
|
||||
localAttachments: <CollaborationAttachment>[
|
||||
CollaborationAttachment(
|
||||
name: 'spec.md',
|
||||
path: '/tmp/workspace/spec.md',
|
||||
description: 'workspace spec',
|
||||
),
|
||||
],
|
||||
aiGatewayBaseUrl: 'https://gateway.example.com',
|
||||
aiGatewayApiKey: 'secret',
|
||||
agentId: '',
|
||||
metadata: <String, dynamic>{},
|
||||
provider: SingleAgentProvider.opencode,
|
||||
);
|
||||
|
||||
final params = request.toAcpParams();
|
||||
|
||||
expect(params['sessionId'], 'session-1');
|
||||
expect(params['threadId'], 'thread-1');
|
||||
expect(params['mode'], 'single-agent');
|
||||
expect(params['workingDirectory'], '/tmp/workspace');
|
||||
expect(params['provider'], 'opencode');
|
||||
expect(params['model'], 'codex-sonnet');
|
||||
expect(params['thinking'], 'medium');
|
||||
expect(params['selectedSkills'], <String>['PPT', 'Browser Automation']);
|
||||
expect(params['attachments'], <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'name': 'spec.md',
|
||||
'description': 'workspace spec',
|
||||
'path': '/tmp/workspace/spec.md',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'name': 'note.txt',
|
||||
'description': 'text/plain',
|
||||
'path': '',
|
||||
},
|
||||
]);
|
||||
expect(params['inlineAttachments'], <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'name': 'note.txt',
|
||||
'mimeType': 'text/plain',
|
||||
'content': 'aGVsbG8=',
|
||||
'sizeBytes': 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'run result prefers completion text and preserves resolved workspace',
|
||||
() {
|
||||
final result = goAgentCoreRunResultFromResponse(
|
||||
<String, dynamic>{
|
||||
'result': <String, dynamic>{
|
||||
'success': true,
|
||||
'turnId': 'turn-7',
|
||||
'summary': 'summary text',
|
||||
'resolvedModel': 'codex-sonnet',
|
||||
'resolvedWorkingDirectory': '/tmp/thread',
|
||||
'resolvedWorkspaceRefKind': 'remotePath',
|
||||
},
|
||||
},
|
||||
streamedText: 'partial output',
|
||||
completedMessage: 'final output',
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.turnId, 'turn-7');
|
||||
expect(result.message, 'final output');
|
||||
expect(result.resolvedModel, 'codex-sonnet');
|
||||
expect(result.resolvedWorkingDirectory, '/tmp/thread');
|
||||
expect(result.resolvedWorkspaceRefKind, WorkspaceRefKind.remotePath);
|
||||
},
|
||||
);
|
||||
|
||||
test('session update recognizes delta notifications', () {
|
||||
final update = goAgentCoreUpdateFromNotification(<String, dynamic>{
|
||||
'method': 'session.update',
|
||||
'params': <String, dynamic>{
|
||||
'sessionId': 'session-2',
|
||||
'threadId': 'thread-2',
|
||||
'turnId': 'turn-2',
|
||||
'type': 'delta',
|
||||
'delta': 'hello',
|
||||
'pending': true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(update, isNotNull);
|
||||
expect(update!.sessionId, 'session-2');
|
||||
expect(update.threadId, 'thread-2');
|
||||
expect(update.turnId, 'turn-2');
|
||||
expect(update.isDelta, isTrue);
|
||||
expect(update.text, 'hello');
|
||||
expect(update.pending, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import '../test_suite_stub.dart'
|
||||
if (dart.library.io) 'direct_single_agent_app_server_suite.dart'
|
||||
if (dart.library.io) 'go_agent_core_client_suite.dart'
|
||||
as suite;
|
||||
|
||||
void main() {
|
||||
@ -19,7 +19,7 @@ void main() {
|
||||
];
|
||||
const guardedFiles = <String>[
|
||||
'lib/app/app_controller_desktop.dart',
|
||||
'lib/runtime/single_agent_runner.dart',
|
||||
'lib/runtime/go_agent_core_client.dart',
|
||||
'lib/runtime/runtime_coordinator.dart',
|
||||
'lib/runtime/gateway_acp_client.dart',
|
||||
];
|
||||
@ -53,5 +53,27 @@ void main() {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('legacy direct single-agent runtime implementation stays removed', () {
|
||||
const removedFiles = <String>[
|
||||
'lib/runtime/direct_single_agent_app_server_client_core.dart',
|
||||
'lib/runtime/direct_single_agent_app_server_client_helpers.dart',
|
||||
'lib/runtime/direct_single_agent_app_server_client_transport.dart',
|
||||
];
|
||||
|
||||
for (final relativePath in removedFiles) {
|
||||
expect(
|
||||
File(relativePath).existsSync(),
|
||||
isFalse,
|
||||
reason: '$relativePath should stay removed after GoAgentCore cutover',
|
||||
);
|
||||
}
|
||||
|
||||
final runnerShim = File('lib/runtime/single_agent_runner.dart');
|
||||
expect(runnerShim.existsSync(), isTrue);
|
||||
final shimContent = runnerShim.readAsStringSync();
|
||||
expect(shimContent.contains('DefaultSingleAgentRunner'), isFalse);
|
||||
expect(shimContent.contains('DirectSingleAgentAppServerClient'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user