diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart index af5f8751..7f8625a6 100644 --- a/lib/app/app_controller_desktop_workspace_execution.dart +++ b/lib/app/app_controller_desktop_workspace_execution.dart @@ -53,6 +53,23 @@ extension AppControllerDesktopWorkspaceExecution on AppController { final currentTarget = assistantExecutionTargetForSession( sessionsControllerInternal.currentSessionKey, ); + final shouldRefreshAgentProviders = + resolvedTarget.isAgent && assistantProviderCatalog.isEmpty; + if (shouldRefreshAgentProviders) { + try { + await refreshSingleAgentCapabilitiesInternal(forceRefresh: true); + } catch (_) { + // Keep target selection interactive even when a just-in-time + // capabilities refresh fails. The provider picker will remain hidden + // until the next successful refresh. + } + if (currentTarget == resolvedTarget && + settings.assistantExecutionTarget == resolvedTarget) { + recomputeTasksInternal(); + notifyIfActiveInternal(); + return; + } + } if (currentTarget == resolvedTarget && settings.assistantExecutionTarget == resolvedTarget) { return; diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 6598ffc6..65c2dd6b 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -1,5 +1,10 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { group('AssistantExecutionTarget', () { @@ -45,5 +50,130 @@ void main() { expect(provider.providerId, kCanonicalGatewayProviderId); expect(provider.label, kCanonicalGatewayProviderLabel); }); + + test( + 'refreshes agent provider catalog when agent mode is selected with an empty catalog', + () async { + final capture = await _startCapabilityServer(); + addTearDown(capture.close); + + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-agent-provider-refresh-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveAccountSyncState( + AccountSyncState.defaults().copyWith( + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + bridgeServerUrl: capture.baseEndpoint.toString(), + ), + syncState: 'ready', + ), + ); + + final controller = AppController( + store: store, + environmentOverride: const { + 'BRIDGE_AUTH_TOKEN': 'bridge-token', + }, + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await _waitForRequest(capture, minimumCount: 1); + + expect(controller.assistantProviderCatalog, isEmpty); + + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.agent, + ); + + expect( + controller.assistantProviderCatalog.map((item) => item.providerId), + containsAll(['codex', 'opencode', 'gemini']), + ); + expect(capture.requestCount, greaterThanOrEqualTo(2)); + expect(capture.lastAuthorizationHeader, 'Bearer bridge-token'); + }, + ); }); } + +Future _waitForRequest( + _CapabilityServerCapture capture, { + required int minimumCount, +}) async { + for (var index = 0; index < 20; index += 1) { + if (capture.requestCount >= minimumCount) { + return; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + fail('Timed out waiting for $minimumCount capability requests'); +} + +Future<_CapabilityServerCapture> _startCapabilityServer() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final capture = _CapabilityServerCapture._( + server, + Uri.parse('http://127.0.0.1:${server.port}'), + ); + server.listen((request) async { + capture.requestCount += 1; + capture.lastAuthorizationHeader = + request.headers.value(HttpHeaders.authorizationHeader) ?? ''; + await utf8.decoder.bind(request).join(); + if (capture.requestCount == 1) { + request.response.statusCode = HttpStatus.internalServerError; + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'error': {'message': 'startup refresh failed'}, + }), + ); + await request.response.close(); + return; + } + + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': 'capabilities', + 'result': { + 'singleAgent': true, + 'multiAgent': true, + 'providerCatalog': >[ + {'providerId': 'codex', 'label': 'Codex'}, + {'providerId': 'opencode', 'label': 'OpenCode'}, + {'providerId': 'gemini', 'label': 'Gemini'}, + ], + }, + }), + ); + await request.response.close(); + }); + return capture; +} + +class _CapabilityServerCapture { + _CapabilityServerCapture._(this._server, this.baseEndpoint); + + final HttpServer _server; + final Uri baseEndpoint; + int requestCount = 0; + String lastAuthorizationHeader = ''; + + Future close() => _server.close(force: true); +}