fix(single-agent): reject empty assistant REST completions

This commit is contained in:
Haitao Pan 2026-03-27 18:47:42 +08:00
parent dc1d3d8c5e
commit f0dec21e97
2 changed files with 117 additions and 65 deletions

View File

@ -106,7 +106,11 @@ class DirectSingleAgentEndpointDescriptor {
);
}
final scheme = endpoint.scheme.toLowerCase();
final normalizedBase = endpoint.replace(path: '', query: null, fragment: null);
final normalizedBase = endpoint.replace(
path: '',
query: null,
fragment: null,
);
final isLocal = _isLocalHost(endpoint.host);
if (scheme == 'ws' && isLocal) {
return DirectSingleAgentEndpointDescriptor(
@ -245,11 +249,11 @@ class DirectSingleAgentAppServerClient {
sessionId,
candidateBases: <Uri>[
for (final entry in _transportKinds.entries)
if (entry.value == _DirectSingleAgentTransportKind.restSessionApi)
...[
if (_describeEndpoint(entry.key).baseUri != null)
_describeEndpoint(entry.key).baseUri!,
],
if (entry.value ==
_DirectSingleAgentTransportKind.restSessionApi) ...[
if (_describeEndpoint(entry.key).baseUri != null)
_describeEndpoint(entry.key).baseUri!,
],
],
);
await _webSocketTransport.abort(sessionId);
@ -262,7 +266,9 @@ class DirectSingleAgentAppServerClient {
DirectSingleAgentEndpointDescriptor _describeEndpoint(
SingleAgentProvider provider,
) {
return DirectSingleAgentEndpointDescriptor.describe(endpointResolver(provider));
return DirectSingleAgentEndpointDescriptor.describe(
endpointResolver(provider),
);
}
Future<_ResolvedSingleAgentTransport> _resolveTransport(
@ -272,16 +278,16 @@ class DirectSingleAgentAppServerClient {
}) async {
final cachedKind = _transportKinds[provider];
if (cachedKind != null) {
final cachedEndpoint = cachedKind ==
_DirectSingleAgentTransportKind.websocketAppServer
final cachedEndpoint =
cachedKind == _DirectSingleAgentTransportKind.websocketAppServer
? descriptor.websocketUri
: descriptor.baseUri;
if (cachedEndpoint != null) {
return _ResolvedSingleAgentTransport(
kind: cachedKind,
endpoint: cachedEndpoint,
websocket: cachedKind ==
_DirectSingleAgentTransportKind.websocketAppServer
websocket:
cachedKind == _DirectSingleAgentTransportKind.websocketAppServer
? _webSocketTransport
: null,
rest: cachedKind == _DirectSingleAgentTransportKind.restSessionApi
@ -356,10 +362,7 @@ class _DirectSingleAgentWebSocketTransport {
final Map<String, String> _threadIds = <String, String>{};
final Set<String> _abortedSessions = <String>{};
Future<void> probe(
Uri endpoint, {
required String gatewayToken,
}) async {
Future<void> probe(Uri endpoint, {required String gatewayToken}) async {
_DirectAppServerConnection? connection;
try {
connection = await _DirectAppServerConnection.connect(
@ -602,10 +605,7 @@ class _DirectSingleAgentRestTransport {
final Map<String, String> _restSessionIds = <String, String>{};
final Set<String> _abortedSessions = <String>{};
Future<void> probe(
Uri base, {
required String gatewayToken,
}) async {
Future<void> probe(Uri base, {required String gatewayToken}) async {
await _fetchJson(
_buildRestUri(base, '/global/health'),
gatewayToken: gatewayToken,
@ -639,6 +639,26 @@ class _DirectSingleAgentRestTransport {
String? lastAssistantText;
var busySeen = false;
bool hasResolvedAssistantContent() {
return output.toString().trim().isNotEmpty ||
(lastAssistantText?.trim().isNotEmpty ?? false);
}
void completeFailure(String message) {
if (completion.isCompleted) {
return;
}
completion.complete(
DirectSingleAgentRunResult(
success: false,
output: output.toString(),
errorMessage: message,
aborted: _abortedSessions.contains(normalizedSessionId),
resolvedModel: request.model,
),
);
}
final eventClient = HttpClient()
..connectionTimeout = const Duration(seconds: 8);
late final HttpClientRequest eventRequest;
@ -652,6 +672,12 @@ class _DirectSingleAgentRestTransport {
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,
@ -707,17 +733,10 @@ class _DirectSingleAgentRestTransport {
}
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,
),
completeFailure(
error['message']?.toString() ??
error['name']?.toString() ??
'OpenCode session failed.',
);
return;
}
@ -733,7 +752,8 @@ class _DirectSingleAgentRestTransport {
if (activeAssistantMessageId != null &&
part['messageID']?.toString().trim() ==
activeAssistantMessageId) {
final delta = properties['text']?.toString() ??
final delta =
properties['text']?.toString() ??
properties['delta']?.toString() ??
'';
if (delta.isNotEmpty) {
@ -793,19 +813,7 @@ class _DirectSingleAgentRestTransport {
completeSuccess();
}
},
onError: (message) {
if (!completion.isCompleted) {
completion.complete(
DirectSingleAgentRunResult(
success: false,
output: output.toString(),
errorMessage: message,
aborted: _abortedSessions.contains(normalizedSessionId),
resolvedModel: request.model,
),
);
}
},
onError: completeFailure,
),
);
@ -930,6 +938,7 @@ class _DirectSingleAgentRestTransport {
}
await Future<void>.delayed(const Duration(milliseconds: 200));
}
onError('OpenCode REST session completed without assistant content.');
}
String _latestAssistantTextFromRestMessages(List<Object?> items) {

View File

@ -256,6 +256,36 @@ void main() {
expect(server.createdSessionCount, 1);
expect(server.lastPromptText, 'hello opencode');
});
test(
'fails OpenCode REST turns that complete without assistant content',
() async {
final server = await _FakeOpenCodeRestServer.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'));
},
);
});
}
@ -506,9 +536,10 @@ class _FakeAppServer {
}
class _FakeOpenCodeRestServer {
_FakeOpenCodeRestServer._(this._server);
_FakeOpenCodeRestServer._(this._server, {required this.emitAssistantContent});
final HttpServer _server;
final bool emitAssistantContent;
final List<HttpResponse> _eventResponses = <HttpResponse>[];
var _sessionCounter = 0;
var _messageCounter = 0;
@ -519,9 +550,14 @@ class _FakeOpenCodeRestServer {
Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${_server.port}');
static Future<_FakeOpenCodeRestServer> start() async {
static Future<_FakeOpenCodeRestServer> start({
bool emitAssistantContent = true,
}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = _FakeOpenCodeRestServer._(server);
final fake = _FakeOpenCodeRestServer._(
server,
emitAssistantContent: emitAssistantContent,
);
unawaited(fake._listen());
return fake;
}
@ -644,32 +680,39 @@ class _FakeOpenCodeRestServer {
},
},
});
for (final delta in <String>['hello ', 'world ', 'from ', 'opencode']) {
if (emitAssistantContent) {
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.delta',
'type': 'message.part.updated',
'properties': <String, dynamic>{
'sessionID': sessionId,
'part': <String, dynamic>{'messageID': assistantMessageId},
'text': delta,
'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': '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',