diff --git a/lib/app/app_controller_desktop_external_acp_routing.dart b/lib/app/app_controller_desktop_external_acp_routing.dart index 06c68a36..33be6bbf 100644 --- a/lib/app/app_controller_desktop_external_acp_routing.dart +++ b/lib/app/app_controller_desktop_external_acp_routing.dart @@ -73,7 +73,8 @@ extension AppControllerDesktopExternalAcpRouting on AppController { !resolvedProvider.isUnspecified ? resolvedProvider.providerId : ''; - final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false + final resolvedExplicitModel = + !currentTarget.isGateway && (thread?.hasExplicitModelSelection ?? false) ? assistantModelForSession(normalizedSessionKey) : ''; final resolvedExplicitSkills = thread?.hasExplicitSkillSelection ?? false diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 6a3a91c8..85169409 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -336,7 +336,9 @@ extension AppControllerDesktopThreadActions on AppController { } } final provider = assistantProviderForSession(sessionKey); - final model = assistantModelForSession(sessionKey); + final model = currentTarget.isGateway + ? '' + : assistantModelForSession(sessionKey); final routing = buildExternalAcpRoutingForSessionInternal(sessionKey); final dispatch = await codeAgentNodeOrchestratorInternal .buildGatewayDispatch( diff --git a/lib/features/assistant/assistant_page_task_dialog_controls.dart b/lib/features/assistant/assistant_page_task_dialog_controls.dart index 03a9beaf..96b19c9c 100644 --- a/lib/features/assistant/assistant_page_task_dialog_controls.dart +++ b/lib/features/assistant/assistant_page_task_dialog_controls.dart @@ -29,16 +29,9 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget { if (supportedExecutionTargets.isEmpty) { return const SizedBox.shrink(); } - final visibleExecutionTargets = controller.visibleAssistantExecutionTargets( - supportedExecutionTargets, - ); - final resolutionTargets = visibleExecutionTargets.isNotEmpty - ? visibleExecutionTargets - : supportedExecutionTargets; - final currentExecutionTarget = resolveAssistantExecutionTargetFromVisibleTargets( - resolutionTargets, + supportedExecutionTargets, currentTarget: controller.assistantExecutionTarget, ); final executionTarget = collapseAssistantExecutionTargetForDisplay( @@ -64,7 +57,6 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget { controller: controller, executionTarget: executionTarget, supportedExecutionTargets: supportedExecutionTargets, - visibleExecutionTargets: visibleExecutionTargets, ), _TaskDialogProviderMenuButtonInternal( controller: controller, @@ -81,13 +73,11 @@ class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget { required this.controller, required this.executionTarget, required this.supportedExecutionTargets, - required this.visibleExecutionTargets, }); final AppController controller; final AssistantExecutionTarget executionTarget; final List supportedExecutionTargets; - final List visibleExecutionTargets; @override Widget build(BuildContext context) { @@ -102,10 +92,8 @@ class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget { }, itemBuilder: (context) => supportedExecutionTargets .map((value) { - final enabled = visibleExecutionTargets.contains(value); return PopupMenuItem( value: value, - enabled: enabled, key: Key('assistant-execution-target-menu-item-${value.name}'), child: Row( children: [ @@ -131,7 +119,7 @@ class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget { AssistantExecutionTarget value, ) async { final resolvedTarget = resolveAssistantExecutionTargetFromVisibleTargets( - visibleExecutionTargets, + supportedExecutionTargets, currentTarget: value, ); await controller.setAssistantExecutionTarget(resolvedTarget); diff --git a/lib/runtime/go_task_service_client.dart b/lib/runtime/go_task_service_client.dart index fe962d7b..5a649960 100644 --- a/lib/runtime/go_task_service_client.dart +++ b/lib/runtime/go_task_service_client.dart @@ -140,6 +140,23 @@ class ExternalCodeAgentAcpRoutingConfig { bool get isAuto => mode == ExternalCodeAgentAcpRoutingMode.auto; + ExternalCodeAgentAcpRoutingConfig withoutExplicitModel() { + if (explicitModel.trim().isEmpty) { + return this; + } + return ExternalCodeAgentAcpRoutingConfig( + mode: mode, + preferredGatewayTarget: preferredGatewayTarget, + explicitExecutionTarget: explicitExecutionTarget, + explicitProviderId: explicitProviderId, + explicitModel: '', + explicitSkills: explicitSkills, + allowSkillInstall: allowSkillInstall, + availableSkills: availableSkills, + installApproval: installApproval, + ); + } + Map toJson() { return { 'routingMode': mode.name, @@ -254,7 +271,10 @@ class GoTaskServiceRequest { routing ?? _synthesizedRouting(); Map toExternalAcpParams() { - final resolvedRouting = effectiveRouting; + final resolvedRouting = normalizedTarget.isGateway + ? effectiveRouting.withoutExplicitModel() + : effectiveRouting; + final requestModel = normalizedTarget.isGateway ? '' : model.trim(); final providerId = provider.isUnspecified ? '' : provider.providerId; final agentProviderId = normalizedTarget.isGateway ? '' : providerId; final params = { @@ -294,7 +314,7 @@ class GoTaskServiceRequest { if (agentProviderId.isNotEmpty) 'provider': agentProviderId, if (remoteWorkingDirectoryHint.trim().isNotEmpty) 'remoteWorkingDirectoryHint': remoteWorkingDirectoryHint.trim(), - if (model.trim().isNotEmpty) 'model': model.trim(), + if (requestModel.isNotEmpty) 'model': requestModel, if (thinking.trim().isNotEmpty) 'thinking': thinking.trim(), 'routing': resolvedRouting.toJson(), if (routingHint.trim().isNotEmpty) 'routingHint': routingHint.trim(), @@ -321,7 +341,7 @@ class GoTaskServiceRequest { final explicitProviderId = provider.isUnspecified || gatewayTarget.isGateway ? '' : provider.providerId; - final explicitModelValue = model.trim(); + final explicitModelValue = gatewayTarget.isGateway ? '' : model.trim(); final explicitSkillsValue = selectedSkills .map((item) => item.trim()) .where((item) => item.isNotEmpty) diff --git a/test/features/assistant/assistant_lower_pane_test.dart b/test/features/assistant/assistant_lower_pane_test.dart index 2c3ed0c1..c56a3bfc 100644 --- a/test/features/assistant/assistant_lower_pane_test.dart +++ b/test/features/assistant/assistant_lower_pane_test.dart @@ -317,6 +317,50 @@ void main() { ); }); + testWidgets( + 'keeps task dialog modes selectable when only OpenClaw is live', + (tester) async { + final controller = AppController( + environmentOverride: const {}, + initialGatewayProviderCatalog: const [ + SingleAgentProvider.openclaw, + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.gateway, + ], + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('draft:test-task-a'); + controller.initializeAssistantThreadContext( + 'draft:test-task-a', + executionTarget: AssistantExecutionTarget.gateway, + messageViewMode: controller.assistantMessageViewModeForSession( + 'draft:test-task-a', + ), + ); + controller.notifyListeners(); + + await tester.pumpWidget( + _buildTestApp(child: _buildLowerPane(controller: controller)), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const Key('assistant-execution-target-button')), + ); + await tester.pumpAndSettle(); + + final agentItem = tester + .widget>( + find.byKey( + const Key('assistant-execution-target-menu-item-agent'), + ), + ); + expect(agentItem.enabled, isTrue); + }, + ); + testWidgets('uses submit button instead of connect action', (tester) async { final controller = AppController( environmentOverride: const {}, diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 508a63b5..cdb5e0bf 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -2066,6 +2066,50 @@ void main() { }, ); + test('OpenClaw gateway task uses the server default model', () async { + final fakeGoTaskService = _BlockingGoTaskServiceClient(); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(() { + fakeGoTaskService.completeAll(); + controller.dispose(); + }); + + await _selectGatewaySession(controller, 'openclaw-default-model-task'); + await controller.selectAssistantModelForSession( + 'openclaw-default-model-task', + 'ollama/kimi-k2.5', + ); + + final taskFuture = controller.sendChatMessage('use OpenClaw default'); + await fakeGoTaskService.waitForRequestCount(1); + + final request = fakeGoTaskService.requests.single; + expect(request.target, AssistantExecutionTarget.gateway); + expect(request.provider, SingleAgentProvider.openclaw); + expect(request.model, isEmpty); + + final params = request.toExternalAcpParams(); + expect(params.containsKey('model'), isFalse); + expect( + params['routing'], + isNot(containsPair('explicitModel', 'ollama/kimi-k2.5')), + ); + + fakeGoTaskService.complete( + 'openclaw-default-model-task', + const GoTaskServiceResult( + success: true, + message: 'result', + turnId: 'turn-openclaw-default-model', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await taskFuture; + }); + test( 'abortRun removes a queued OpenClaw task without bridge cancel', () async {