xworkmate-app/test/runtime/gateway_acp_client_suite.dart

351 lines
10 KiB
Dart

@TestOn('vm')
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/gateway_acp_client.dart';
import 'package:xworkmate/runtime/multi_agent_orchestrator.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
void main() {
group('GatewayAcpClient', () {
test('loads ACP capabilities over websocket when available', () async {
final server = await _AcpFakeServer.start();
addTearDown(server.close);
final client = GatewayAcpClient(
endpointResolver: () => server.baseHttpUri,
);
final capabilities = await client.loadCapabilities(forceRefresh: true);
expect(capabilities.singleAgent, isTrue);
expect(capabilities.multiAgent, isTrue);
expect(capabilities.providers, contains(SingleAgentProvider.codex));
expect(server.rpcMethods, contains('acp.capabilities'));
});
test('falls back to HTTP+SSE when websocket is unavailable', () async {
final server = await _AcpFakeServer.start(disableWebSocket: true);
addTearDown(server.close);
final client = GatewayAcpClient(
endpointResolver: () => server.baseHttpUri,
);
final capabilities = await client.loadCapabilities(forceRefresh: true);
expect(capabilities.singleAgent, isTrue);
expect(capabilities.multiAgent, isTrue);
expect(capabilities.providers, contains(SingleAgentProvider.claude));
expect(server.rpcMethods, contains('acp.capabilities'));
});
test(
'streams multi-agent events and supports cancel/close session',
() async {
final server = await _AcpFakeServer.start();
addTearDown(server.close);
final client = GatewayAcpClient(
endpointResolver: () => server.baseHttpUri,
);
final events = await client
.runMultiAgent(
GatewayAcpMultiAgentRequest(
sessionId: 'session-ma',
threadId: 'thread-ma',
prompt: 'run multi-agent',
workingDirectory: '/tmp',
attachments: const <CollaborationAttachment>[],
selectedSkills: const <String>['design'],
aiGatewayBaseUrl: 'https://example.invalid',
aiGatewayApiKey: 'test-key',
resumeSession: false,
),
)
.toList();
expect(events, isNotEmpty);
expect(events.first.type, 'step');
expect(events.last.type, 'result');
expect(events.last.error, isFalse);
await client.cancelSession(
sessionId: 'session-ma',
threadId: 'thread-ma',
);
await client.closeSession(
sessionId: 'session-ma',
threadId: 'thread-ma',
);
expect(server.rpcMethods, contains('session.cancel'));
expect(server.rpcMethods, contains('session.close'));
},
);
});
}
class _AcpFakeServer {
_AcpFakeServer._(this._server, {required this.disableWebSocket});
final HttpServer _server;
final bool disableWebSocket;
final List<String> rpcMethods = <String>[];
Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${_server.port}');
static Future<_AcpFakeServer> start({bool disableWebSocket = false}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = _AcpFakeServer._(server, disableWebSocket: disableWebSocket);
unawaited(fake._listen());
return fake;
}
Future<void> close() async {
await _server.close(force: true);
}
Future<void> _listen() async {
await for (final request in _server) {
if (!disableWebSocket &&
request.uri.path == '/acp' &&
WebSocketTransformer.isUpgradeRequest(request)) {
final socket = await WebSocketTransformer.upgrade(request);
unawaited(_handleWebSocket(socket));
continue;
}
if (request.uri.path == '/acp/rpc' && request.method == 'POST') {
await _handleHttpRpc(request);
continue;
}
request.response
..statusCode = HttpStatus.notFound
..write('not found');
await request.response.close();
}
}
Future<void> _handleWebSocket(WebSocket socket) async {
await for (final raw in socket) {
final envelope = _decodeMap(raw);
final id = envelope['id'];
final method = envelope['method']?.toString() ?? '';
final params = _asMap(envelope['params']);
if (method.isEmpty) {
continue;
}
rpcMethods.add(method);
await _dispatch(
method: method,
id: id,
params: params,
notify: (notification) async {
socket.add(jsonEncode(notification));
},
respond: (response) async {
socket.add(jsonEncode(response));
},
);
}
}
Future<void> _handleHttpRpc(HttpRequest request) async {
final body = await utf8.decodeStream(request);
final envelope = _decodeMap(body);
final id = envelope['id'];
final method = envelope['method']?.toString() ?? '';
final params = _asMap(envelope['params']);
if (method.isEmpty) {
request.response.statusCode = HttpStatus.badRequest;
await request.response.close();
return;
}
rpcMethods.add(method);
request.response.headers.set(
HttpHeaders.contentTypeHeader,
'text/event-stream',
);
request.response.headers.set(HttpHeaders.cacheControlHeader, 'no-cache');
Future<void> notify(Map<String, dynamic> notification) async {
request.response.write('data: ${jsonEncode(notification)}\n\n');
await request.response.flush();
}
Future<void> respond(Map<String, dynamic> response) async {
request.response.write('data: ${jsonEncode(response)}\n\n');
await request.response.flush();
await request.response.close();
}
await _dispatch(
method: method,
id: id,
params: params,
notify: notify,
respond: respond,
);
}
Future<void> _dispatch({
required String method,
required Object? id,
required Map<String, dynamic> params,
required Future<void> Function(Map<String, dynamic> notification) notify,
required Future<void> Function(Map<String, dynamic> response) respond,
}) async {
switch (method) {
case 'acp.capabilities':
await respond(
_resultEnvelope(
id: id,
result: <String, dynamic>{
'singleAgent': true,
'multiAgent': true,
'providers': <String>['codex', 'claude', 'gemini', 'opencode'],
'capabilities': <String, dynamic>{
'single_agent': true,
'multi_agent': true,
'providers': <String>['codex', 'claude', 'gemini', 'opencode'],
},
},
),
);
return;
case 'session.start':
case 'session.message':
final sessionId = params['sessionId']?.toString() ?? 'session-default';
final threadId = params['threadId']?.toString() ?? sessionId;
final mode = params['mode']?.toString() ?? 'single-agent';
if (mode == 'multi-agent') {
await notify(
_notificationEnvelope(
method: 'multi_agent.event',
params: <String, dynamic>{
'type': 'step',
'title': 'Architect',
'message': 'planning',
'pending': false,
'error': false,
'data': <String, dynamic>{'seq': 1},
},
),
);
await respond(
_resultEnvelope(
id: id,
result: <String, dynamic>{
'success': true,
'summary': 'multi-agent done',
'finalScore': 9,
'iterations': 1,
},
),
);
return;
}
final provider = params['provider']?.toString() ?? 'unknown';
await notify(
_notificationEnvelope(
method: 'session.update',
params: <String, dynamic>{
'sessionId': sessionId,
'threadId': threadId,
'turnId': 'turn-single',
'type': 'delta',
'delta': 'delta-single',
'seq': 1,
'mode': 'single-agent',
},
),
);
await respond(
_resultEnvelope(
id: id,
result: <String, dynamic>{
'success': true,
'output': 'single-agent result ($provider)',
'turnId': 'turn-single',
},
),
);
return;
case 'session.cancel':
await respond(
_resultEnvelope(
id: id,
result: const <String, dynamic>{
'accepted': true,
'cancelled': true,
},
),
);
return;
case 'session.close':
await respond(
_resultEnvelope(
id: id,
result: const <String, dynamic>{'accepted': true, 'closed': true},
),
);
return;
default:
await respond(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'error': <String, dynamic>{
'code': -32601,
'message': 'method not found',
},
});
}
}
Map<String, dynamic> _resultEnvelope({
required Object? id,
required Map<String, dynamic> result,
}) {
return <String, dynamic>{'jsonrpc': '2.0', 'id': id, 'result': result};
}
Map<String, dynamic> _notificationEnvelope({
required String method,
required Map<String, dynamic> params,
}) {
return <String, dynamic>{
'jsonrpc': '2.0',
'method': method,
'params': params,
};
}
Map<String, dynamic> _decodeMap(Object raw) {
if (raw is String) {
final decoded = jsonDecode(raw);
return _asMap(decoded);
}
if (raw is List<int>) {
final decoded = jsonDecode(utf8.decode(raw));
return _asMap(decoded);
}
return _asMap(raw);
}
Map<String, dynamic> _asMap(Object? raw) {
if (raw is Map<String, dynamic>) {
return raw;
}
if (raw is Map) {
return raw.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
}