merge: bring in ACP bridge failure diagnostics
This commit is contained in:
commit
4caff8506b
@ -580,6 +580,26 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse(
|
||||
String? completedMessage,
|
||||
}) {
|
||||
final result = _castMap(response['result']);
|
||||
final skillCandidates = _castMapList(result['skillCandidates'])
|
||||
.map((item) => item['id']?.toString().trim() ?? '')
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
final fallbackFailureText = () {
|
||||
final success = _boolValue(result['success']) ?? true;
|
||||
if (success) {
|
||||
return '';
|
||||
}
|
||||
final errorText = result['error']?.toString().trim() ?? '';
|
||||
final needsSkillInstall = _boolValue(result['needsSkillInstall']) ?? false;
|
||||
if (needsSkillInstall && skillCandidates.isNotEmpty) {
|
||||
final candidateText = skillCandidates.join(', ');
|
||||
if (errorText.isNotEmpty) {
|
||||
return '$errorText (skills: $candidateText)';
|
||||
}
|
||||
return 'Skill install required: $candidateText';
|
||||
}
|
||||
return errorText;
|
||||
}();
|
||||
final responseText =
|
||||
(result['output']?.toString().trim().isNotEmpty == true
|
||||
? result['output'].toString().trim()
|
||||
@ -592,6 +612,8 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse(
|
||||
? completedMessage!.trim()
|
||||
: responseText.isNotEmpty
|
||||
? responseText
|
||||
: fallbackFailureText.isNotEmpty
|
||||
? fallbackFailureText
|
||||
: streamedText.trim().isNotEmpty
|
||||
? streamedText.trim()
|
||||
: '')
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@ -13,14 +12,14 @@ import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
void main() {
|
||||
final config = _BridgeRealTestConfig.load();
|
||||
final skipReason = config.skipReason;
|
||||
final bridgeClient = config.bridgeClient;
|
||||
final artifactService = DesktopThreadArtifactService();
|
||||
|
||||
group('xworkmate-bridge real E2E', () {
|
||||
test(
|
||||
'bridge contract keeps HTTP RPC reachable and advertises single-agent support',
|
||||
() async {
|
||||
final capabilities = await bridgeClient.loadCapabilities(
|
||||
await config.syncExternalProviders();
|
||||
final capabilities = await config.bridgeClient.loadCapabilities(
|
||||
forceRefresh: true,
|
||||
);
|
||||
|
||||
@ -35,6 +34,7 @@ void main() {
|
||||
test(
|
||||
'scenario ${scenario.key} binds thread workdir, supports follow-up, and records artifacts',
|
||||
() async {
|
||||
await config.syncExternalProviders();
|
||||
final root = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-bridge-${scenario.key}-',
|
||||
);
|
||||
@ -57,7 +57,7 @@ void main() {
|
||||
resumeSession: false,
|
||||
);
|
||||
|
||||
final firstResponse = await bridgeClient.request(
|
||||
final firstResponse = await config.bridgeClient.request(
|
||||
method: 'session.start',
|
||||
params: firstRequest.toExternalAcpParams(),
|
||||
);
|
||||
@ -66,6 +66,12 @@ void main() {
|
||||
route: firstRequest.route,
|
||||
);
|
||||
|
||||
_expectSuccessfulBridgeResult(
|
||||
firstResponse,
|
||||
firstResult,
|
||||
scenarioKey: scenario.key,
|
||||
phase: 'start',
|
||||
);
|
||||
expect(firstResult.turnId, isNotEmpty);
|
||||
expect(firstResult.message, isNotEmpty);
|
||||
expect(
|
||||
@ -85,7 +91,7 @@ void main() {
|
||||
prompt: scenario.followUpPrompt,
|
||||
resumeSession: true,
|
||||
);
|
||||
final resumeResponse = await bridgeClient.request(
|
||||
final resumeResponse = await config.bridgeClient.request(
|
||||
method: 'session.message',
|
||||
params: resumeRequest.toExternalAcpParams(),
|
||||
);
|
||||
@ -94,6 +100,12 @@ void main() {
|
||||
route: resumeRequest.route,
|
||||
);
|
||||
|
||||
_expectSuccessfulBridgeResult(
|
||||
resumeResponse,
|
||||
resumeResult,
|
||||
scenarioKey: scenario.key,
|
||||
phase: 'resume',
|
||||
);
|
||||
expect(resumeResult.turnId, isNotEmpty);
|
||||
expect(resumeResult.message, isNotEmpty);
|
||||
expect(
|
||||
@ -237,10 +249,34 @@ class _BridgeRealTestConfig {
|
||||
const _BridgeRealTestConfig({
|
||||
required this.skipReason,
|
||||
required this.bridgeClient,
|
||||
required this.bridgeAuthToken,
|
||||
required this.syncedProviders,
|
||||
});
|
||||
|
||||
final String? skipReason;
|
||||
final GatewayAcpClient bridgeClient;
|
||||
final String bridgeAuthToken;
|
||||
final List<ExternalCodeAgentAcpSyncedProvider> syncedProviders;
|
||||
|
||||
Future<void> syncExternalProviders() async {
|
||||
await bridgeClient.request(
|
||||
method: 'xworkmate.providers.sync',
|
||||
params: <String, dynamic>{
|
||||
'providers': syncedProviders
|
||||
.map(
|
||||
(item) => <String, dynamic>{
|
||||
'providerId': item.providerId,
|
||||
'label': item.label,
|
||||
'endpoint': item.endpoint,
|
||||
'authorizationHeader': item.authorizationHeader,
|
||||
'enabled': item.enabled,
|
||||
},
|
||||
)
|
||||
.toList(growable: false),
|
||||
},
|
||||
authorizationOverride: 'Bearer $bridgeAuthToken',
|
||||
);
|
||||
}
|
||||
|
||||
static _BridgeRealTestConfig load() {
|
||||
final env = <String, String>{..._loadEnvFile(), ...Platform.environment};
|
||||
@ -259,18 +295,68 @@ class _BridgeRealTestConfig {
|
||||
skipReason:
|
||||
'Set BRIDGE_SERVER_URL and BRIDGE_AUTH_TOKEN (or ACP_AUTH_TOKEN) to run real bridge E2E tests.',
|
||||
bridgeClient: GatewayAcpClient(endpointResolver: () => null),
|
||||
bridgeAuthToken: '',
|
||||
syncedProviders: const <ExternalCodeAgentAcpSyncedProvider>[],
|
||||
);
|
||||
}
|
||||
|
||||
final endpoint = _normalizeEndpoint(rawUrl);
|
||||
final normalizedToken = token.trim();
|
||||
final codexProviderEndpoint =
|
||||
env['CODEX_PROVIDER_ENDPOINT'] ?? 'https://acp-server.svc.plus/codex';
|
||||
final client = GatewayAcpClient(
|
||||
endpointResolver: () => endpoint,
|
||||
authorizationResolver: (_) async => 'Bearer ${token.trim()}',
|
||||
authorizationResolver: (_) async => 'Bearer $normalizedToken',
|
||||
);
|
||||
return _BridgeRealTestConfig(
|
||||
skipReason: null,
|
||||
bridgeClient: client,
|
||||
bridgeAuthToken: normalizedToken,
|
||||
syncedProviders: <ExternalCodeAgentAcpSyncedProvider>[
|
||||
ExternalCodeAgentAcpSyncedProvider(
|
||||
providerId: SingleAgentProvider.codex.providerId,
|
||||
label: 'codex',
|
||||
endpoint: codexProviderEndpoint,
|
||||
authorizationHeader: 'Bearer $normalizedToken',
|
||||
enabled: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
return _BridgeRealTestConfig(skipReason: null, bridgeClient: client);
|
||||
}
|
||||
}
|
||||
|
||||
void _expectSuccessfulBridgeResult(
|
||||
Map<String, dynamic> response,
|
||||
GoTaskServiceResult result, {
|
||||
required String scenarioKey,
|
||||
required String phase,
|
||||
}) {
|
||||
final raw = Map<String, dynamic>.from(result.raw);
|
||||
final success = result.success;
|
||||
final errorText = raw['error']?.toString().trim() ?? '';
|
||||
final needsSkillInstall = raw['needsSkillInstall'] == true;
|
||||
final provider = raw['provider']?.toString().trim() ?? '';
|
||||
final skillCandidates =
|
||||
(raw['skillCandidates'] as List?)
|
||||
?.map(
|
||||
(item) => item is Map ? item['id']?.toString().trim() ?? '' : '',
|
||||
)
|
||||
.where((item) => item.isNotEmpty)
|
||||
.cast<String>()
|
||||
.toList(growable: false) ??
|
||||
const <String>[];
|
||||
|
||||
expect(
|
||||
success,
|
||||
isTrue,
|
||||
reason:
|
||||
'bridge $phase should succeed for $scenarioKey. '
|
||||
'error="$errorText", needsSkillInstall=$needsSkillInstall, '
|
||||
'provider="$provider", skillCandidates=$skillCandidates, '
|
||||
'response=$response',
|
||||
);
|
||||
}
|
||||
|
||||
Uri _normalizeEndpoint(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.startsWith('https:') && !trimmed.startsWith('https://')) {
|
||||
|
||||
@ -265,6 +265,47 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('run result falls back to error text when failed payload has no message', () {
|
||||
final result = goTaskServiceResultFromAcpResponse(
|
||||
<String, dynamic>{
|
||||
'result': <String, dynamic>{
|
||||
'success': false,
|
||||
'turnId': 'turn-error',
|
||||
'error': 'missing bearer authorization',
|
||||
},
|
||||
},
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.turnId, 'turn-error');
|
||||
expect(result.message, 'missing bearer authorization');
|
||||
});
|
||||
|
||||
test(
|
||||
'run result includes skill install diagnostics when failed payload requires install',
|
||||
() {
|
||||
final result = goTaskServiceResultFromAcpResponse(
|
||||
<String, dynamic>{
|
||||
'result': <String, dynamic>{
|
||||
'success': false,
|
||||
'turnId': 'turn-skill-install',
|
||||
'error': 'missing bearer authorization',
|
||||
'needsSkillInstall': true,
|
||||
'skillCandidates': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'id': 'pptx', 'label': 'pptx'},
|
||||
],
|
||||
},
|
||||
},
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.turnId, 'turn-skill-install');
|
||||
expect(result.message, 'missing bearer authorization (skills: pptx)');
|
||||
},
|
||||
);
|
||||
|
||||
test('session update recognizes delta notifications', () {
|
||||
final update = goTaskServiceUpdateFromAcpNotification(<String, dynamic>{
|
||||
'method': 'session.update',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user