Fix OpenCode single-agent ACP transport

This commit is contained in:
Haitao Pan 2026-03-27 11:21:05 +08:00
parent 1ad60c6461
commit 2d1d8ecb42
2 changed files with 741 additions and 1 deletions

View File

@ -75,6 +75,7 @@ class DirectSingleAgentAppServerClient {
final Map<String, _DirectAppServerConnection> _activeConnections =
<String, _DirectAppServerConnection>{};
final Map<String, String> _threadIds = <String, String>{};
final Map<String, String> _restSessionIds = <String, String>{};
final Set<String> _abortedSessions = <String>{};
final Map<SingleAgentProvider, DirectSingleAgentCapabilities>
@ -97,6 +98,37 @@ class DirectSingleAgentAppServerClient {
}
final endpoint = _resolveWebSocketEndpoint(provider);
if (_usesRestSessionApi(provider)) {
final base = endpointResolver(provider);
if (base == null) {
final unavailable = const DirectSingleAgentCapabilities.unavailable(
endpoint: '',
errorMessage: 'Single-agent app-server endpoint is not configured.',
);
_cachedCapabilities[provider] = unavailable;
_capabilitiesRefreshedAt[provider] = DateTime.now();
return unavailable;
}
try {
await _fetchJson(
_buildRestUri(base, '/global/health'),
gatewayToken: gatewayToken,
);
_cachedCapabilities[provider] = DirectSingleAgentCapabilities(
available: true,
supportedProviders: <SingleAgentProvider>[provider],
endpoint: base.toString(),
);
} catch (error) {
_cachedCapabilities[provider] = DirectSingleAgentCapabilities.unavailable(
endpoint: base.toString(),
errorMessage: error.toString(),
);
} finally {
_capabilitiesRefreshedAt[provider] = DateTime.now();
}
return _cachedCapabilities[provider]!;
}
if (endpoint == null) {
final unavailable = const DirectSingleAgentCapabilities.unavailable(
endpoint: '',
@ -135,6 +167,9 @@ class DirectSingleAgentAppServerClient {
Future<DirectSingleAgentRunResult> run(
DirectSingleAgentRunRequest request,
) async {
if (_usesRestSessionApi(request.provider)) {
return _runViaRestApi(request);
}
final endpoint = _resolveWebSocketEndpoint(request.provider);
if (endpoint == null) {
return const DirectSingleAgentRunResult(
@ -302,6 +337,22 @@ class DirectSingleAgentAppServerClient {
return;
}
_abortedSessions.add(normalizedSessionId);
final restSessionId = _restSessionIds[normalizedSessionId]?.trim() ?? '';
if (restSessionId.isNotEmpty) {
final provider = SingleAgentProvider.opencode;
final base = endpointResolver(provider);
if (base != null) {
try {
await _postJson(
_buildRestUri(base, '/session/$restSessionId/abort'),
body: null,
gatewayToken: '',
);
} catch (_) {
// Best effort only.
}
}
}
final connection = _activeConnections[normalizedSessionId];
final threadId = _threadIds[normalizedSessionId];
if (connection == null || threadId == null || threadId.isEmpty) {
@ -365,6 +416,442 @@ class DirectSingleAgentAppServerClient {
return threadId;
}
Future<DirectSingleAgentRunResult> _runViaRestApi(
DirectSingleAgentRunRequest request,
) async {
final base = endpointResolver(request.provider);
if (base == null) {
return const DirectSingleAgentRunResult(
success: false,
output: '',
errorMessage: 'Single-agent REST endpoint is missing.',
);
}
final normalizedSessionId = request.sessionId.trim();
if (normalizedSessionId.isEmpty) {
return const DirectSingleAgentRunResult(
success: false,
output: '',
errorMessage: 'Single-agent session id is missing.',
);
}
_abortedSessions.remove(normalizedSessionId);
final remoteSessionId = await _ensureRestSession(
base,
sessionId: normalizedSessionId,
workingDirectory: request.workingDirectory,
gatewayToken: request.gatewayToken,
);
final output = StringBuffer();
final completion = Completer<DirectSingleAgentRunResult>();
String? activeAssistantMessageId;
String? lastAssistantText;
var busySeen = false;
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 ?? '');
completion.complete(
DirectSingleAgentRunResult(
success: true,
output: resolvedOutput,
errorMessage: '',
resolvedModel: request.model,
),
);
}
try {
final eventUri = _buildRestUri(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 = _decodeMap(line.substring(6));
final payload = _asMap(event['payload']);
final type = payload['type']?.toString().trim() ?? '';
final properties = _asMap(payload['properties']);
if (properties['sessionID']?.toString().trim() !=
remoteSessionId) {
return;
}
if (type == 'session.status') {
final status = _asMap(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 = _asMap(properties['error']);
completion.complete(
DirectSingleAgentRunResult(
success: false,
output: output.toString(),
errorMessage:
error['message']?.toString() ??
error['name']?.toString() ??
'OpenCode session failed.',
aborted: _abortedSessions.contains(normalizedSessionId),
resolvedModel: request.model,
),
);
return;
}
if (type == 'message.updated') {
final info = _asMap(properties['info']);
if (info['role']?.toString().trim() == 'assistant') {
activeAssistantMessageId = info['id']?.toString().trim();
}
return;
}
if (type == 'message.part.delta') {
final part = _asMap(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 = _asMap(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) {
// OpenCode event streams can disconnect independently from the
// backing session lifecycle. Keep polling session state instead.
},
onDone: () {},
cancelOnError: true,
);
await _postJson(
_buildRestUri(
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(
_pollRestAssistantMessage(
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: (message) {
if (!completion.isCompleted) {
completion.complete(
DirectSingleAgentRunResult(
success: false,
output: output.toString(),
errorMessage: message,
aborted: _abortedSessions.contains(normalizedSessionId),
resolvedModel: request.model,
),
);
}
},
),
);
return await completion.future.timeout(
const Duration(minutes: 10),
onTimeout: () => DirectSingleAgentRunResult(
success: false,
output: output.toString(),
errorMessage: 'OpenCode REST request timed out.',
aborted: _abortedSessions.contains(normalizedSessionId),
resolvedModel: request.model,
),
);
} catch (error) {
return DirectSingleAgentRunResult(
success: false,
output: output.toString(),
errorMessage: error.toString(),
aborted: _abortedSessions.contains(normalizedSessionId),
resolvedModel: request.model,
);
} finally {
unawaited(lineSubscription?.cancel());
eventClient.close(force: true);
_abortedSessions.remove(normalizedSessionId);
}
}
Future<String> _ensureRestSession(
Uri base, {
required String sessionId,
required String workingDirectory,
required String gatewayToken,
}) async {
final existing = _restSessionIds[sessionId]?.trim() ?? '';
if (existing.isNotEmpty) {
return existing;
}
final created = await _postJson(
_buildRestUri(
base,
'/session',
queryParameters: <String, String>{'directory': workingDirectory},
),
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.');
}
_restSessionIds[sessionId] = createdId;
return createdId;
}
Future<void> _pollRestAssistantMessage(
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 _fetchJsonList(
_buildRestUri(
base,
'/session/$remoteSessionId/message',
queryParameters: <String, String>{
'directory': workingDirectory,
'limit': '20',
},
),
gatewayToken: gatewayToken,
);
final text = _latestAssistantTextFromRestMessages(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));
}
}
String _latestAssistantTextFromRestMessages(List<Object?> items) {
for (final raw in items.reversed) {
final item = _asMap(raw);
final info = _asMap(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 = _asMap(rawPart);
if (part['type']?.toString().trim() == 'text') {
final text = part['text']?.toString() ?? '';
if (text.trim().isNotEmpty) {
return text;
}
}
}
}
return '';
}
bool _usesRestSessionApi(SingleAgentProvider provider) {
if (provider.providerId != SingleAgentProvider.opencode.providerId) {
return false;
}
final base = endpointResolver(provider);
final scheme = base?.scheme.toLowerCase() ?? '';
return scheme == 'http' || scheme == 'https';
}
Uri _buildRestUri(
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>> _fetchJson(
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 _decodeMap(body);
} finally {
client.close(force: true);
}
}
Future<Map<String, dynamic>> _postJson(
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 _decodeMap(text);
} finally {
client.close(force: true);
}
}
Future<List<Object?>> _fetchJsonList(
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? _extractThreadId(Map<String, dynamic> payload) {
final topLevelId = payload['id']?.toString().trim() ?? '';
if (topLevelId.isNotEmpty) {

View File

@ -50,7 +50,7 @@ void main() {
).copyWith(onOutput: deltas.add),
);
expect(result.success, isTrue);
expect(result.success, isTrue, reason: result.errorMessage);
expect(result.output, 'hello world from app server');
expect(result.resolvedModel, 'codex-sonnet');
expect(server.lastTurnInput, <Object?>[
@ -175,6 +175,54 @@ void main() {
expect(result.resolvedModel, 'codex-sonnet');
},
);
test('probes OpenCode REST endpoint and reports provider support', () async {
final server = await _FakeOpenCodeRestServer.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 _FakeOpenCodeRestServer.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');
});
});
}
@ -424,6 +472,211 @@ class _FakeAppServer {
}
}
class _FakeOpenCodeRestServer {
_FakeOpenCodeRestServer._(this._server);
final HttpServer _server;
final List<HttpResponse> _eventResponses = <HttpResponse>[];
var _sessionCounter = 0;
var _messageCounter = 0;
bool healthRequested = false;
int createdSessionCount = 0;
String lastPromptText = '';
final Map<String, String> _assistantTextBySession = <String, String>{};
Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${_server.port}');
static Future<_FakeOpenCodeRestServer> start() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = _FakeOpenCodeRestServer._(server);
unawaited(fake._listen());
return fake;
}
Future<void> close() async {
for (final response in _eventResponses.toList(growable: false)) {
try {
await response.close();
} catch (_) {
// Best effort.
}
}
await _server.close(force: true);
}
Future<void> _listen() async {
await for (final request in _server) {
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();
_eventResponses.add(request.response);
continue;
}
if (request.uri.path == '/session' && request.method == 'POST') {
createdSessionCount += 1;
final sessionId = 'ses-${_sessionCounter++}';
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 = _assistantTextBySession[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-${_messageCounter++}';
await _broadcastEvent(
<String, dynamic>{
'payload': <String, dynamic>{
'type': 'session.status',
'properties': <String, dynamic>{
'sessionID': sessionId,
'status': <String, dynamic>{'type': 'busy'},
},
},
},
);
await _broadcastEvent(
<String, dynamic>{
'payload': <String, dynamic>{
'type': 'message.updated',
'properties': <String, dynamic>{
'sessionID': sessionId,
'info': <String, dynamic>{
'id': assistantMessageId,
'role': 'assistant',
},
},
},
},
);
for (final delta in <String>['hello ', 'world ', 'from ', 'opencode']) {
await _broadcastEvent(
<String, dynamic>{
'payload': <String, dynamic>{
'type': 'message.part.delta',
'properties': <String, dynamic>{
'sessionID': sessionId,
'part': <String, dynamic>{'messageID': assistantMessageId},
'text': delta,
},
},
},
);
}
await _broadcastEvent(
<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',
},
},
},
},
);
_assistantTextBySession[sessionId] = 'hello world from opencode';
await _broadcastEvent(
<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> _broadcastEvent(Map<String, dynamic> event) async {
final payload = 'data: ${jsonEncode(event)}\n\n';
for (final response in _eventResponses.toList(growable: false)) {
response.write(payload);
await response.flush();
}
}
}
Map<String, dynamic> _decodeMap(Object raw) {
if (raw is Map<String, dynamic>) {
return raw;