diff --git a/lib/runtime/gateway_runtime_api.dart b/lib/runtime/gateway_runtime_api.dart index 6f074fdf..8fc56fb8 100644 --- a/lib/runtime/gateway_runtime_api.dart +++ b/lib/runtime/gateway_runtime_api.dart @@ -213,23 +213,35 @@ extension GatewayRuntimeApiInternal on GatewayRuntime { allowErrorPayload: true, ), ); - return asList(payload['skills']) + final statusPayload = skillsStatusPayloadInternal(payload); + return asList(statusPayload['skills']) .map((item) { final map = asMap(item); return GatewaySkillSummary( - name: stringValue(map['name']) ?? 'Skill', + name: + stringValue(map['name']) ?? + stringValue(map['title']) ?? + stringValue(map['id']) ?? + 'Skill', description: stringValue(map['description']) ?? '', source: stringValue(map['source']) ?? 'workspace', skillKey: stringValue(map['skillKey']) ?? + stringValue(map['skill_key']) ?? + stringValue(map['key']) ?? + stringValue(map['id']) ?? stringValue(map['name']) ?? 'skill', primaryEnv: stringValue(map['primaryEnv']), eligible: boolValue(map['eligible']) ?? false, disabled: boolValue(map['disabled']) ?? false, - missingBins: stringList(asMap(map['missing'])['bins']), - missingEnv: stringList(asMap(map['missing'])['env']), - missingConfig: stringList(asMap(map['missing'])['config']), + missingBins: skillMissingListInternal(map, 'bins', 'missingBins'), + missingEnv: skillMissingListInternal(map, 'env', 'missingEnv'), + missingConfig: skillMissingListInternal( + map, + 'config', + 'missingConfig', + ), ); }) .toList(growable: false); @@ -548,3 +560,33 @@ extension GatewayRuntimeApiInternal on GatewayRuntime { return result.payload; } } + +Map skillsStatusPayloadInternal(Map payload) { + if (asList(payload['skills']).isNotEmpty) { + return payload; + } + for (final key in const [ + 'status', + 'skillStatus', + 'data', + 'payload', + ]) { + final nested = asMap(payload[key]); + if (asList(nested['skills']).isNotEmpty) { + return nested; + } + } + return payload; +} + +List skillMissingListInternal( + Map skill, + String nestedKey, + String flatKey, +) { + final flat = stringList(skill[flatKey]); + if (flat.isNotEmpty) { + return flat; + } + return stringList(asMap(skill['missing'])[nestedKey]); +} diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index cd443ec2..76322ded 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -3797,7 +3797,7 @@ void main() { controller.assistantSessionHasPendingRun('openclaw-failed-task'), isFalse, ); - expect(controller.openClawGatewayActiveTasksInternal, 0); + await _waitForOpenClawActiveTaskCount(controller, 0); expect( controller .requireTaskThreadForSessionInternal('openclaw-failed-task') @@ -4384,6 +4384,22 @@ Future _waitForThreadLastResultCode( ); } +Future _waitForOpenClawActiveTaskCount( + AppController controller, + int expectedCount, +) async { + final deadline = DateTime.now().add(const Duration(seconds: 15)); + while (DateTime.now().isBefore(deadline)) { + if (controller.openClawGatewayActiveTasksInternal == expectedCount) { + return; + } + await Future.delayed(const Duration(milliseconds: 10)); + } + throw StateError( + 'Timed out waiting for OpenClaw active task count $expectedCount. Current count: ${controller.openClawGatewayActiveTasksInternal}.', + ); +} + class _RecordingGoTaskServiceClient implements GoTaskServiceClient { int executeCount = 0; final List requests = []; diff --git a/test/runtime/gateway_runtime_bridge_skills_test.dart b/test/runtime/gateway_runtime_bridge_skills_test.dart index 84bfd2d4..2d5d11be 100644 --- a/test/runtime/gateway_runtime_bridge_skills_test.dart +++ b/test/runtime/gateway_runtime_bridge_skills_test.dart @@ -236,4 +236,109 @@ void main() { expect(controller.items.single.eligible, isFalse); }, ); + + test( + 'GatewayRuntime loads skills from nested bridge status payload', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final subscription = server.listen((request) async { + final body = await utf8.decoder.bind(request).join(); + final rpc = jsonDecode(body) as Map; + final method = rpc['method']?.toString().trim() ?? ''; + request.response.headers.contentType = ContentType.json; + + if (method == 'xworkmate.gateway.connect') { + request.response.write( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': { + 'ok': true, + 'snapshot': { + 'status': 'connected', + 'mode': 'remote', + 'statusText': 'Connected', + 'mainSessionKey': 'main', + }, + 'auth': {'role': 'operator'}, + 'returnedDeviceToken': '', + }, + }), + ); + await request.response.close(); + return; + } + + if (method == 'xworkmate.gateway.request') { + request.response.write( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': { + 'ok': true, + 'payload': { + 'status': { + 'workspaceDir': '/home/ubuntu/.openclaw/workspace', + 'managedSkillsDir': '/home/ubuntu/.openclaw/skills', + 'skills': >[ + { + 'name': 'Browser Automation', + 'description': 'Drive browser workflows.', + 'source': 'agent', + 'id': 'browser-automation', + 'eligible': true, + 'disabled': false, + 'missingBins': [], + 'missingEnv': [], + 'missingConfig': [], + }, + ], + }, + }, + }, + }), + ); + await request.response.close(); + return; + } + + request.response.statusCode = HttpStatus.badRequest; + await request.response.close(); + }); + + final tempDir = await Directory.systemTemp.createTemp( + 'xworkmate-bridge-skills-nested-test-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => '${tempDir.path}/settings.sqlite3', + secretRootPathResolver: () async => tempDir.path, + ); + final acpClient = GatewayAcpClient( + endpointResolver: () => Uri.parse('http://127.0.0.1:${server.port}'), + authorizationResolver: (_) async => 'bridge-token', + ); + final identityStore = DeviceIdentityStore(store); + final runtime = GatewayRuntime( + store: store, + identityStore: identityStore, + sessionClient: GatewayAcpRuntimeSessionClient(client: acpClient), + ); + await runtime.initialize(); + addTearDown(() async { + runtime.dispose(); + await subscription.cancel(); + await server.close(force: true); + await tempDir.delete(recursive: true); + }); + + final controller = SkillsController(runtime); + await controller.refresh(agentId: 'main'); + + expect(controller.error, isNull); + expect(controller.items, hasLength(1)); + expect(controller.items.single.skillKey, 'browser-automation'); + expect(controller.items.single.missingBins, isEmpty); + }, + ); }