From 2acd784b27d5db5ba2d45bd5b5ef3ffb4256407d Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 23 Mar 2026 12:54:20 +0800 Subject: [PATCH] Refine single-agent thread scoped provider flow --- lib/app/app_controller_desktop.dart | 469 +++++++++++++++--- lib/features/assistant/assistant_page.dart | 79 ++- lib/runtime/single_agent_runner.dart | 105 +++- lib/widgets/assistant_focus_panel.dart | 40 +- .../app_controller_ai_gateway_chat_suite.dart | 137 ++++- ...pp_controller_ai_gateway_models_suite.dart | 86 +++- ...troller_execution_target_switch_suite.dart | 4 +- .../app_controller_thread_skills_suite.dart | 54 +- 8 files changed, 840 insertions(+), 134 deletions(-) diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 33767065..83408544 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -47,6 +47,7 @@ class AppController extends ChangeNotifier { DesktopPlatformService? desktopPlatformService, UiFeatureManifest? uiFeatureManifest, List? gatewayOnlySkillScanRoots, + List? availableSingleAgentProvidersOverride, SingleAgentRunner? singleAgentRunner, }) { _store = store ?? SecureConfigStore(); @@ -92,6 +93,8 @@ class AppController extends ChangeNotifier { (_isFlutterTestEnvironment ? const [] : _defaultGatewayOnlySkillScanRoots); + _availableSingleAgentProvidersOverride = + availableSingleAgentProvidersOverride; _arisBundleRepository = ArisBundleRepository(); _arisBridgeLocator = ArisBridgeLocator(); _multiAgentMountManager = MultiAgentMountManager( @@ -129,6 +132,7 @@ class AppController extends ChangeNotifier { late final DerivedTasksController _tasksController; late final DesktopPlatformService _desktopPlatformService; late final List _gatewayOnlySkillScanRoots; + late final List? _availableSingleAgentProvidersOverride; late final ArisBundleRepository _arisBundleRepository; late final ArisBridgeLocator _arisBridgeLocator; late final MultiAgentMountManager _multiAgentMountManager; @@ -150,6 +154,7 @@ class AppController extends ChangeNotifier { {}; final Set _aiGatewayPendingSessionKeys = {}; final Set _aiGatewayAbortedSessionKeys = {}; + final Set _singleAgentExternalCliPendingSessionKeys = {}; final Set _activeMultiAgentBrokerSessions = {}; bool _multiAgentRunPending = false; int _localMessageCounter = 0; @@ -309,7 +314,20 @@ class AppController extends ChangeNotifier { hasStoredAiGatewayApiKey && resolvedAiGatewayModel.isNotEmpty; + List get availableSingleAgentProviders => + SingleAgentProvider.values + .where((item) => item != SingleAgentProvider.auto) + .where(_canUseSingleAgentProvider) + .toList(growable: false); + + bool get hasAnyAvailableSingleAgentProvider => + availableSingleAgentProviders.isNotEmpty; + bool _canUseSingleAgentProvider(SingleAgentProvider provider) { + final override = _availableSingleAgentProvidersOverride; + if (override != null) { + return provider != SingleAgentProvider.auto && override.contains(provider); + } if (provider == SingleAgentProvider.auto) { return settings.multiAgent.mountTargets.any( (item) => @@ -325,6 +343,23 @@ class AppController extends ChangeNotifier { ); } + SingleAgentProvider? _resolvedSingleAgentProvider( + SingleAgentProvider selection, + ) { + if (selection != SingleAgentProvider.auto) { + return _canUseSingleAgentProvider(selection) ? selection : null; + } + for (final provider in SingleAgentProvider.values) { + if (provider == SingleAgentProvider.auto) { + continue; + } + if (_canUseSingleAgentProvider(provider)) { + return provider; + } + } + return null; + } + List get aiGatewayConversationModelChoices { final selected = settings.aiGateway.selectedModels .map((item) => item.trim()) @@ -365,7 +400,7 @@ class AppController extends ChangeNotifier { String _resolvedAssistantModelForTarget(AssistantExecutionTarget target) { if (target == AssistantExecutionTarget.singleAgent) { - return resolvedAiGatewayModel; + return ''; } final resolved = resolvedDefaultModel.trim(); if (resolved.isNotEmpty) { @@ -390,6 +425,18 @@ class AppController extends ChangeNotifier { const []; } + int assistantSkillCountForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (assistantExecutionTargetForSession(normalizedSessionKey) == + AssistantExecutionTarget.singleAgent) { + return assistantImportedSkillsForSession(normalizedSessionKey).length; + } + return skills.length; + } + + int get currentAssistantSkillCount => + assistantSkillCountForSession(currentSessionKey); + List assistantSelectedSkillKeysForSession(String sessionKey) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); final importedKeys = assistantImportedSkillsForSession( @@ -413,6 +460,10 @@ class AppController extends ChangeNotifier { if (recordModel.isNotEmpty) { return recordModel; } + if (target == AssistantExecutionTarget.singleAgent && + singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) { + return resolvedAiGatewayModel; + } return _resolvedAssistantModelForTarget(target); } @@ -425,6 +476,85 @@ class AppController extends ChangeNotifier { SingleAgentProvider get currentSingleAgentProvider => singleAgentProviderForSession(currentSessionKey); + SingleAgentProvider? singleAgentResolvedProviderForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + return _resolvedSingleAgentProvider( + singleAgentProviderForSession(normalizedSessionKey), + ); + } + + SingleAgentProvider? get currentSingleAgentResolvedProvider => + singleAgentResolvedProviderForSession(currentSessionKey); + + bool singleAgentUsesAiChatFallbackForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (assistantExecutionTargetForSession(normalizedSessionKey) != + AssistantExecutionTarget.singleAgent) { + return false; + } + return !hasAnyAvailableSingleAgentProvider && canUseAiGatewayConversation; + } + + bool get currentSingleAgentUsesAiChatFallback => + singleAgentUsesAiChatFallbackForSession(currentSessionKey); + + bool singleAgentNeedsAiGatewayConfigurationForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (assistantExecutionTargetForSession(normalizedSessionKey) != + AssistantExecutionTarget.singleAgent) { + return false; + } + return !hasAnyAvailableSingleAgentProvider && !canUseAiGatewayConversation; + } + + bool get currentSingleAgentNeedsAiGatewayConfiguration => + singleAgentNeedsAiGatewayConfigurationForSession(currentSessionKey); + + bool singleAgentHasResolvedProviderForSession(String sessionKey) { + return singleAgentResolvedProviderForSession(sessionKey) != null; + } + + bool get currentSingleAgentHasResolvedProvider => + singleAgentHasResolvedProviderForSession(currentSessionKey); + + bool singleAgentShouldSuggestAutoSwitchForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (assistantExecutionTargetForSession(normalizedSessionKey) != + AssistantExecutionTarget.singleAgent) { + return false; + } + final selection = singleAgentProviderForSession(normalizedSessionKey); + if (selection == SingleAgentProvider.auto) { + return false; + } + return !_canUseSingleAgentProvider(selection) && + hasAnyAvailableSingleAgentProvider; + } + + bool get currentSingleAgentShouldSuggestAutoSwitch => + singleAgentShouldSuggestAutoSwitchForSession(currentSessionKey); + + String singleAgentModelDisplayLabelForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final model = assistantModelForSession(normalizedSessionKey); + if (model.isNotEmpty) { + return model; + } + if (singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) { + return appText('AI Chat fallback', 'AI Chat fallback'); + } + final provider = + singleAgentResolvedProviderForSession(normalizedSessionKey) ?? + singleAgentProviderForSession(normalizedSessionKey); + return appText( + '请先配置 ${provider.label} 模型', + 'Configure ${provider.label} model', + ); + } + + String get currentSingleAgentModelDisplayLabel => + singleAgentModelDisplayLabelForSession(currentSessionKey); + List get singleAgentProviderOptions => SingleAgentProvider.values; @@ -436,14 +566,18 @@ class AppController extends ChangeNotifier { if (!isSingleAgentMode) { return activeAgentName; } + final resolvedProvider = currentSingleAgentResolvedProvider; + if (resolvedProvider != null) { + return resolvedProvider.label; + } final provider = currentSingleAgentProvider; if (provider != SingleAgentProvider.auto) { return provider.label; } - final model = resolvedAssistantModel; - return model.isEmpty - ? appText('单机智能体', 'Single Agent') - : appText('单机智能体', 'Single Agent'); + if (currentSingleAgentUsesAiChatFallback) { + return appText('AI Chat fallback', 'AI Chat fallback'); + } + return appText('单机智能体', 'Single Agent'); } AssistantThreadConnectionState get currentAssistantConnectionState => @@ -456,24 +590,50 @@ class AppController extends ChangeNotifier { final target = assistantExecutionTargetForSession(normalizedSessionKey); if (target == AssistantExecutionTarget.singleAgent) { final provider = singleAgentProviderForSession(normalizedSessionKey); + final resolvedProvider = + singleAgentResolvedProviderForSession(normalizedSessionKey); final model = assistantModelForSession(normalizedSessionKey); + final fallbackReady = + singleAgentUsesAiChatFallbackForSession(normalizedSessionKey); final host = _aiGatewayHostLabel(settings.aiGateway.baseUrl); - final detail = _joinConnectionParts([ - provider.label, - model, - host, - ]); - final providerReady = _canUseSingleAgentProvider(provider); + final providerReady = resolvedProvider != null; + final detail = providerReady + ? _joinConnectionParts([ + resolvedProvider.label, + model, + ]) + : fallbackReady + ? _joinConnectionParts([ + appText('AI Chat fallback', 'AI Chat fallback'), + model, + host, + ]) + : singleAgentShouldSuggestAutoSwitchForSession(normalizedSessionKey) + ? appText( + '${provider.label} 不可用,可切到 Auto', + '${provider.label} is unavailable. Switch to Auto.', + ) + : singleAgentNeedsAiGatewayConfigurationForSession( + normalizedSessionKey, + ) + ? appText( + '没有可用的外部 CLI,请配置 AI Gateway fallback。', + 'No external CLI is available. Configure AI Gateway fallback.', + ) + : appText( + '当前线程的外部 CLI 尚未就绪。', + 'The external CLI for this thread is not ready yet.', + ); return AssistantThreadConnectionState( executionTarget: target, - status: providerReady || canUseAiGatewayConversation + status: providerReady || fallbackReady ? RuntimeConnectionStatus.connected : RuntimeConnectionStatus.offline, primaryLabel: target.label, detailLabel: detail.isEmpty - ? appText('AI Gateway 未配置', 'AI Gateway not configured') + ? appText('未配置单机智能体', 'Single Agent is not configured') : detail, - ready: providerReady || canUseAiGatewayConversation, + ready: providerReady || fallbackReady, pairingRequired: false, gatewayTokenMissing: false, lastError: null, @@ -723,7 +883,17 @@ class AppController extends ChangeNotifier { List _assistantModelChoicesForSession(String sessionKey) { final target = assistantExecutionTargetForSession(sessionKey); if (target == AssistantExecutionTarget.singleAgent) { - return aiGatewayConversationModelChoices; + if (singleAgentUsesAiChatFallbackForSession(sessionKey)) { + return aiGatewayConversationModelChoices; + } + final selectedModel = _assistantThreadRecords[ + _normalizedAssistantSessionKey(sessionKey)] + ?.assistantModelId + .trim(); + if (selectedModel?.isNotEmpty == true) { + return [selectedModel!]; + } + return const []; } final runtimeModels = connectedGatewayModelChoices; if (runtimeModels.isNotEmpty) { @@ -1490,6 +1660,18 @@ class AppController extends ChangeNotifier { return; } if (isSingleAgentMode) { + final sessionKey = _normalizedAssistantSessionKey( + _sessionsController.currentSessionKey, + ); + if (_singleAgentExternalCliPendingSessionKeys.contains(sessionKey)) { + await _singleAgentRunner.abort(sessionKey); + _aiGatewayPendingSessionKeys.remove(sessionKey); + _singleAgentExternalCliPendingSessionKeys.remove(sessionKey); + _clearAiGatewayStreamingText(sessionKey); + _recomputeTasks(); + _notifyIfActive(); + return; + } await _abortAiGatewayRun(_sessionsController.currentSessionKey); return; } @@ -1540,10 +1722,17 @@ class AppController extends ChangeNotifier { _upsertAssistantThreadRecord( sessionKey, singleAgentProvider: provider, + discoveredSkills: const [], + importedSkills: const [], + selectedSkillKeys: const [], updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); _recomputeTasks(); _notifyIfActive(); + if (assistantExecutionTargetForSession(sessionKey) == + AssistantExecutionTarget.singleAgent) { + await discoverGatewayOnlySkillsForSession(sessionKey); + } unawaited(refreshMultiAgentMounts(sync: settings.multiAgent.autoSync)); } @@ -1722,7 +1911,9 @@ class AppController extends ChangeNotifier { return; } - final discovered = await _scanGatewayOnlySkillCandidates(); + final discovered = await _scanGatewayOnlySkillCandidatesForSession( + normalizedSessionKey, + ); _upsertAssistantThreadRecord( normalizedSessionKey, discoveredSkills: const [], @@ -2077,6 +2268,7 @@ class AppController extends ChangeNotifier { _aiGatewayStreamingClients.clear(); _aiGatewayPendingSessionKeys.clear(); _aiGatewayAbortedSessionKeys.clear(); + _singleAgentExternalCliPendingSessionKeys.clear(); _activeMultiAgentBrokerSessions.clear(); _multiAgentRunPending = false; setActiveAppLanguage(defaults.appLanguage); @@ -2929,28 +3121,48 @@ class AppController extends ChangeNotifier { ); final provider = resolution.resolvedProvider; if (provider == null) { - _appendAssistantThreadMessage( - sessionKey, - GatewayChatMessage( - id: _nextLocalMessageId(), - role: 'assistant', - text: _singleAgentFallbackLabel(resolution.fallbackReason), - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: 'AI Chat fallback', - stopReason: null, - pending: false, - error: false, - ), - ); - await _sendAiGatewayMessage( - message, - thinking: thinking, - attachments: attachments, - sessionKeyOverride: sessionKey, - appendUserMessage: false, - managePendingState: false, - ); + if (singleAgentUsesAiChatFallbackForSession(sessionKey)) { + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: _singleAgentFallbackLabel(resolution.fallbackReason), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: 'AI Chat fallback', + stopReason: null, + pending: false, + error: false, + ), + ); + await _sendAiGatewayMessage( + message, + thinking: thinking, + attachments: attachments, + sessionKeyOverride: sessionKey, + appendUserMessage: false, + managePendingState: false, + ); + } else { + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: _singleAgentUnavailableLabel( + sessionKey, + resolution.fallbackReason, + ), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: provider?.label ?? selection.label, + stopReason: null, + pending: false, + error: false, + ), + ); + } return; } @@ -2971,9 +3183,11 @@ class AppController extends ChangeNotifier { error: false, ), ); + _singleAgentExternalCliPendingSessionKeys.add(sessionKey); final result = await _singleAgentRunner.run( SingleAgentRunRequest( + sessionId: sessionKey, provider: provider, prompt: message, model: assistantModelForSession(sessionKey), @@ -2984,34 +3198,76 @@ class AppController extends ChangeNotifier { aiGatewayBaseUrl: aiGatewayUrl, aiGatewayApiKey: await loadAiGatewayApiKey(), config: settings.multiAgent, + onOutput: (text) => _setAiGatewayStreamingText(sessionKey, text), configuredCodexCliPath: configuredCodexCliPath, ), ); - if (result.shouldFallbackToAiChat) { - _appendAssistantThreadMessage( - sessionKey, - GatewayChatMessage( - id: _nextLocalMessageId(), - role: 'assistant', - text: _singleAgentFallbackLabel( - result.fallbackReason ?? result.errorMessage, + _clearAiGatewayStreamingText(sessionKey); + if (result.aborted) { + final partial = result.output.trim(); + if (partial.isNotEmpty) { + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: partial, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: 'aborted', + pending: false, + error: false, ), - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: 'AI Chat fallback', - stopReason: null, - pending: false, - error: false, - ), - ); - await _sendAiGatewayMessage( - message, - thinking: thinking, - attachments: attachments, - sessionKeyOverride: sessionKey, - appendUserMessage: false, - managePendingState: false, - ); + ); + } + return; + } + if (result.shouldFallbackToAiChat) { + if (singleAgentUsesAiChatFallbackForSession(sessionKey)) { + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: _singleAgentFallbackLabel( + result.fallbackReason ?? result.errorMessage, + ), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: 'AI Chat fallback', + stopReason: null, + pending: false, + error: false, + ), + ); + await _sendAiGatewayMessage( + message, + thinking: thinking, + attachments: attachments, + sessionKeyOverride: sessionKey, + appendUserMessage: false, + managePendingState: false, + ); + } else { + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: _singleAgentUnavailableLabel( + sessionKey, + result.fallbackReason ?? result.errorMessage, + ), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: provider.label, + stopReason: null, + pending: false, + error: false, + ), + ); + } return; } @@ -3043,11 +3299,14 @@ class AppController extends ChangeNotifier { ), ); } catch (error) { + _clearAiGatewayStreamingText(sessionKey); _appendAssistantThreadMessage( sessionKey, _assistantErrorMessage(error.toString()), ); } finally { + _singleAgentExternalCliPendingSessionKeys.remove(sessionKey); + _clearAiGatewayStreamingText(sessionKey); _aiGatewayPendingSessionKeys.remove(sessionKey); _recomputeTasks(); _notifyIfActive(); @@ -3426,6 +3685,43 @@ class AppController extends ChangeNotifier { ); } + String _singleAgentUnavailableLabel(String sessionKey, String? reason) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final detail = reason?.trim() ?? ''; + final selection = singleAgentProviderForSession(normalizedSessionKey); + if (singleAgentShouldSuggestAutoSwitchForSession(normalizedSessionKey)) { + return detail.isEmpty + ? appText( + '当前线程固定为 ${selection.label},但它在这台设备上不可用。检测到其他外部 CLI 时不会自动改线,可切到 Auto。', + 'This thread is pinned to ${selection.label}, but it is unavailable on this device. XWorkmate will not reroute to another external CLI automatically. Switch to Auto instead.', + ) + : appText( + '当前线程固定为 ${selection.label}:$detail 检测到其他外部 CLI 时不会自动改线,可切到 Auto。', + 'This thread is pinned to ${selection.label}: $detail XWorkmate will not reroute to another external CLI automatically. Switch to Auto instead.', + ); + } + if (singleAgentNeedsAiGatewayConfigurationForSession(normalizedSessionKey)) { + return detail.isEmpty + ? appText( + '当前没有可用的外部 CLI,也没有可用的 AI Chat fallback。请先安装外部 CLI,或配置 AI Gateway。', + 'No external CLI is available, and AI Chat fallback is not configured. Install an external CLI or configure AI Gateway first.', + ) + : appText( + '$detail 当前没有可用的外部 CLI,也没有可用的 AI Chat fallback。请先安装外部 CLI,或配置 AI Gateway。', + '$detail No external CLI is available, and AI Chat fallback is not configured. Install an external CLI or configure AI Gateway first.', + ); + } + return detail.isEmpty + ? appText( + '当前线程的外部 CLI 尚未就绪。', + 'The external CLI for this thread is not ready yet.', + ) + : appText( + '当前线程的外部 CLI 尚未就绪:$detail', + 'The external CLI for this thread is not ready yet: $detail', + ); + } + void _appendAssistantThreadMessage( String sessionKey, GatewayChatMessage message, @@ -3561,8 +3857,13 @@ class AppController extends ChangeNotifier { return target.promptValue; } - Future> - _scanGatewayOnlySkillCandidates() async { + Future> _scanGatewayOnlySkillCandidatesForSession( + String sessionKey, + ) async { + final provider = singleAgentResolvedProviderForSession(sessionKey); + if (provider == null) { + return const []; + } final home = Platform.environment['HOME']?.trim() ?? ''; if (home.isEmpty && _gatewayOnlySkillScanRoots.every((item) => !item.startsWith('/'))) { @@ -3571,6 +3872,12 @@ class AppController extends ChangeNotifier { final entries = []; final seen = {}; for (final relativeRoot in _gatewayOnlySkillScanRoots) { + if (!_shouldIncludeSingleAgentSkillRoot( + relativeRoot, + provider: provider, + )) { + continue; + } final root = Directory( relativeRoot.startsWith('/') ? relativeRoot : '$home/$relativeRoot', ); @@ -3596,6 +3903,40 @@ class AppController extends ChangeNotifier { return entries; } + bool _shouldIncludeSingleAgentSkillRoot( + String root, { + required SingleAgentProvider provider, + }) { + final normalized = root.trim().toLowerCase(); + if (normalized.isEmpty) { + return false; + } + if (normalized.contains('workbuddy')) { + return true; + } + if (normalized.contains('openclaw')) { + return false; + } + final scopedProvider = _providerForSingleAgentSkillRoot(normalized); + return scopedProvider == provider; + } + + SingleAgentProvider? _providerForSingleAgentSkillRoot(String root) { + if (root.contains('codex')) { + return SingleAgentProvider.codex; + } + if (root.contains('opencode')) { + return SingleAgentProvider.opencode; + } + if (root.contains('claude')) { + return SingleAgentProvider.claude; + } + if (root.contains('gemini')) { + return SingleAgentProvider.gemini; + } + return null; + } + Future _skillEntryFromFile( File file, String rootPath, diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 52396e48..90177a80 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -459,7 +459,9 @@ class _AssistantPageState extends State { inputController: _inputController, focusNode: _composerFocusNode, thinkingLabel: _thinkingLabel, - modelLabel: controller.resolvedAssistantModel.isEmpty + modelLabel: controller.isSingleAgentMode + ? controller.currentSingleAgentModelDisplayLabel + : controller.resolvedAssistantModel.isEmpty ? appText('未选择模型', 'No model selected') : controller.resolvedAssistantModel, modelOptions: controller.assistantModelChoices, @@ -2031,7 +2033,7 @@ class _AssistantTaskRailState extends State<_AssistantTaskRail> { ), _MetaPill( label: - '${appText('技能', 'Skills')} ${widget.controller.skills.length}', + '${appText('技能', 'Skills')} ${widget.controller.currentAssistantSkillCount}', icon: Icons.auto_awesome_rounded, ), ], @@ -2343,11 +2345,19 @@ class _AssistantEmptyState extends StatelessWidget { final connectionState = controller.currentAssistantConnectionState; final singleAgent = connectionState.isSingleAgent; final connected = connectionState.connected; + final singleAgentFallback = controller.currentSingleAgentUsesAiChatFallback; + final singleAgentNeedsAiGateway = + controller.currentSingleAgentNeedsAiGatewayConfiguration; + final singleAgentSuggestsAuto = + controller.currentSingleAgentShouldSuggestAutoSwitch; + final providerLabel = controller.currentSingleAgentProvider.label; final reconnectAvailable = controller.canQuickConnectGateway; final title = singleAgent ? connected - ? appText('开始单机智能体任务', 'Start a single-agent task') - : appText('先配置 AI Gateway', 'Configure AI Gateway first') + ? appText('开始单机智能体任务', 'Start a single-agent task') + : singleAgentNeedsAiGateway + ? appText('先配置 AI Gateway', 'Configure AI Gateway first') + : appText('先准备外部 CLI', 'Prepare the external CLI first') : connected ? appText('开始对话或运行任务', 'Start a chat or run a task') : connectionState.status == RuntimeConnectionStatus.error @@ -2355,14 +2365,29 @@ class _AssistantEmptyState extends StatelessWidget { : appText('先连接 Gateway', 'Connect a gateway first'); final description = singleAgent ? connected - ? appText( - '当前模式使用单机智能体处理当前任务,不会建立 OpenClaw Gateway 会话。', - 'This mode uses a single agent for the current task and does not open an OpenClaw Gateway session.', - ) - : appText( - '请先在 设置 -> 集成 中配置 AI Gateway 地址、API Key 和默认模型,然后以单机智能体模式继续当前任务。', - 'Set the AI Gateway URL, API key, and default model in Settings -> Integrations, then continue this task in Single Agent mode.', - ) + ? (singleAgentFallback + ? appText( + '当前没有可用的外部 CLI,这个线程已降级到 AI Chat fallback,不会建立 OpenClaw Gateway 会话。', + 'No external CLI is available for this thread, so it is running in AI Chat fallback without opening an OpenClaw Gateway session.', + ) + : appText( + '当前模式使用单机智能体处理当前任务,不会建立 OpenClaw Gateway 会话。', + 'This mode uses a single agent for the current task and does not open an OpenClaw Gateway session.', + )) + : singleAgentSuggestsAuto + ? appText( + '当前线程固定为 $providerLabel,但它在这台设备上不可用。检测到其他外部 CLI 时不会自动切换,可在工具栏里改成 Auto。', + 'This thread is pinned to $providerLabel, but it is unavailable on this device. XWorkmate will not switch to another external CLI automatically. Change the provider to Auto in the toolbar.', + ) + : singleAgentNeedsAiGateway + ? appText( + '请先在 设置 -> 集成 中配置 AI Gateway 地址、API Key 和默认模型,然后以单机智能体模式继续当前任务。', + 'Set the AI Gateway URL, API key, and default model in Settings -> Integrations, then continue this task in Single Agent mode.', + ) + : appText( + '当前线程的外部 CLI 尚未就绪。请先安装或配置 $providerLabel,或切换到 Auto。', + 'The external CLI for this thread is not ready yet. Install or configure $providerLabel first, or switch to Auto.', + ) : connected ? appText( '输入需求后即可开始执行,结果会回到当前会话并同步到任务页。', @@ -2415,7 +2440,9 @@ class _AssistantEmptyState extends StatelessWidget { onPressed: connected ? onFocusComposer : singleAgent - ? onOpenAiGatewaySettings + ? singleAgentNeedsAiGateway + ? onOpenAiGatewaySettings + : onFocusComposer : reconnectAvailable ? () async { await onReconnectGateway(); @@ -2425,7 +2452,9 @@ class _AssistantEmptyState extends StatelessWidget { connected ? Icons.edit_rounded : singleAgent - ? Icons.tune_rounded + ? singleAgentNeedsAiGateway + ? Icons.tune_rounded + : Icons.smart_toy_outlined : reconnectAvailable ? Icons.refresh_rounded : Icons.link_rounded, @@ -2434,7 +2463,9 @@ class _AssistantEmptyState extends StatelessWidget { connected ? appText('开始输入', 'Start typing') : singleAgent - ? appText('打开配置中心', 'Open settings') + ? singleAgentNeedsAiGateway + ? appText('打开配置中心', 'Open settings') + : appText('查看线程工具栏', 'Open toolbar') : reconnectAvailable ? appText('重新连接', 'Reconnect') : appText('连接 Gateway', 'Connect gateway'), @@ -2450,7 +2481,7 @@ class _AssistantEmptyState extends StatelessWidget { ), ), ), - if (!connected) + if (!connected && (!singleAgent || singleAgentNeedsAiGateway)) OutlinedButton.icon( onPressed: singleAgent ? onOpenAiGatewaySettings @@ -2584,6 +2615,8 @@ class _ComposerBarState extends State<_ComposerBar> { final connectionState = controller.currentAssistantConnectionState; final singleAgent = connectionState.isSingleAgent; final connected = connectionState.connected; + final singleAgentNeedsAiGateway = + controller.currentSingleAgentNeedsAiGatewayConfiguration; final reconnectAvailable = controller.canQuickConnectGateway; final connecting = connectionState.connecting; final executionTarget = controller.assistantExecutionTarget; @@ -2595,7 +2628,9 @@ class _ComposerBarState extends State<_ComposerBar> { final submitLabel = connected ? appText('提交', 'Submit') : singleAgent - ? appText('配置 AI Gateway', 'Configure AI Gateway') + ? singleAgentNeedsAiGateway + ? appText('配置 AI Gateway', 'Configure AI Gateway') + : appText('查看工具栏', 'Open toolbar') : connecting ? appText('连接中…', 'Connecting…') : reconnectAvailable @@ -3002,7 +3037,11 @@ class _ComposerBarState extends State<_ComposerBar> { : connected ? widget.onSend : singleAgent - ? widget.onOpenAiGatewaySettings + ? singleAgentNeedsAiGateway + ? widget.onOpenAiGatewaySettings + : () { + widget.focusNode.requestFocus(); + } : reconnectAvailable ? () async { await widget.onReconnectGateway(); @@ -3025,7 +3064,9 @@ class _ComposerBarState extends State<_ComposerBar> { connected ? Icons.arrow_upward_rounded : singleAgent - ? Icons.hub_outlined + ? singleAgentNeedsAiGateway + ? Icons.hub_outlined + : Icons.smart_toy_outlined : reconnectAvailable ? Icons.refresh_rounded : Icons.link_rounded, diff --git a/lib/runtime/single_agent_runner.dart b/lib/runtime/single_agent_runner.dart index 7ee85313..66acdfab 100644 --- a/lib/runtime/single_agent_runner.dart +++ b/lib/runtime/single_agent_runner.dart @@ -18,6 +18,7 @@ class SingleAgentProviderResolution { class SingleAgentRunRequest { const SingleAgentRunRequest({ + required this.sessionId, required this.provider, required this.prompt, required this.model, @@ -27,9 +28,11 @@ class SingleAgentRunRequest { required this.aiGatewayBaseUrl, required this.aiGatewayApiKey, required this.config, + this.onOutput, this.configuredCodexCliPath = '', }); + final String sessionId; final SingleAgentProvider provider; final String prompt; final String model; @@ -39,6 +42,7 @@ class SingleAgentRunRequest { final String aiGatewayBaseUrl; final String aiGatewayApiKey; final MultiAgentConfig config; + final void Function(String text)? onOutput; final String configuredCodexCliPath; } @@ -49,6 +53,7 @@ class SingleAgentRunResult { required this.success, required this.errorMessage, required this.shouldFallbackToAiChat, + this.aborted = false, this.fallbackReason, }); @@ -57,6 +62,7 @@ class SingleAgentRunResult { final bool success; final String errorMessage; final bool shouldFallbackToAiChat; + final bool aborted; final String? fallbackReason; } @@ -67,6 +73,8 @@ abstract class SingleAgentRunner { }); Future run(SingleAgentRunRequest request); + + Future abort(String sessionId); } class DefaultSingleAgentRunner implements SingleAgentRunner { @@ -94,6 +102,8 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { final Future Function(String command)? _binaryExistsResolver; final CliProcessStarter _processStarter; + final Map _activeProcesses = {}; + final Set _abortedSessionIds = {}; @override Future resolveProvider({ @@ -164,22 +174,56 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { ? null : request.workingDirectory, ); + _activeProcesses[request.sessionId] = process; await process.stdin.close(); final timeout = Duration(seconds: request.config.timeoutSeconds); - final stdout = await process.stdout + final stdoutBuffer = StringBuffer(); + final stderrBuffer = StringBuffer(); + final stdoutFuture = process.stdout .transform(utf8.decoder) - .join() - .timeout(timeout, onTimeout: () => ''); - final stderr = await process.stderr + .listen((chunk) { + if (chunk.isEmpty) { + return; + } + stdoutBuffer.write(chunk); + request.onOutput?.call(stdoutBuffer.toString()); + }) + .asFuture(); + final stderrFuture = process.stderr .transform(utf8.decoder) - .join() - .timeout(timeout, onTimeout: () => ''); - final exitCode = await process.exitCode.timeout( - timeout, - onTimeout: () => -1, - ); + .listen((chunk) { + if (chunk.isEmpty) { + return; + } + stderrBuffer.write(chunk); + }) + .asFuture(); + final exitCode = await process.exitCode.timeout(timeout, onTimeout: () { + try { + process.kill(ProcessSignal.sigkill); + } catch (_) { + // Best effort only. + } + return -1; + }); + await Future.wait(>[ + stdoutFuture.timeout(timeout, onTimeout: () {}), + stderrFuture.timeout(timeout, onTimeout: () {}), + ]); - final output = stdout.trim().isNotEmpty ? stdout.trim() : stderr.trim(); + final output = stdoutBuffer.toString().trim().isNotEmpty + ? stdoutBuffer.toString().trim() + : stderrBuffer.toString().trim(); + if (_abortedSessionIds.remove(request.sessionId)) { + return SingleAgentRunResult( + provider: request.provider, + output: output, + success: false, + errorMessage: 'aborted', + shouldFallbackToAiChat: false, + aborted: true, + ); + } if (exitCode == 0 && output.isNotEmpty) { return SingleAgentRunResult( provider: request.provider, @@ -190,20 +234,33 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { ); } - final fallbackReason = _isLaunchFailureExit(exitCode, stderr) + final fallbackReason = _isLaunchFailureExit( + exitCode, + stderrBuffer.toString(), + ) ? '${request.provider.label} CLI could not be launched.' : null; return SingleAgentRunResult( provider: request.provider, output: output, success: false, - errorMessage: stderr.trim().isNotEmpty - ? stderr.trim() + errorMessage: stderrBuffer.toString().trim().isNotEmpty + ? stderrBuffer.toString().trim() : 'CLI exited with code $exitCode', shouldFallbackToAiChat: fallbackReason != null, fallbackReason: fallbackReason, ); } catch (error) { + if (_abortedSessionIds.remove(request.sessionId)) { + return SingleAgentRunResult( + provider: request.provider, + output: '', + success: false, + errorMessage: 'aborted', + shouldFallbackToAiChat: false, + aborted: true, + ); + } final fallbackReason = _isLaunchFailureError(error) ? '${request.provider.label} CLI could not be launched.' : null; @@ -215,6 +272,26 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { shouldFallbackToAiChat: fallbackReason != null, fallbackReason: fallbackReason, ); + } finally { + _activeProcesses.remove(request.sessionId); + } + } + + @override + Future abort(String sessionId) async { + final normalized = sessionId.trim(); + if (normalized.isEmpty) { + return; + } + _abortedSessionIds.add(normalized); + final process = _activeProcesses[normalized]; + if (process == null) { + return; + } + try { + process.kill(ProcessSignal.sigterm); + } catch (_) { + // Best effort only. } } diff --git a/lib/widgets/assistant_focus_panel.dart b/lib/widgets/assistant_focus_panel.dart index 1222a8e3..d46b1f39 100644 --- a/lib/widgets/assistant_focus_panel.dart +++ b/lib/widgets/assistant_focus_panel.dart @@ -401,11 +401,40 @@ class _SkillsFocusPreview extends StatelessWidget { @override Widget build(BuildContext context) { - final items = controller.skills.take(4).toList(growable: false); + final items = controller.isSingleAgentMode + ? controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .take(4) + .map( + (skill) => GatewaySkillSummary( + name: skill.label, + description: skill.description, + source: skill.sourcePath, + skillKey: skill.key, + primaryEnv: null, + eligible: true, + disabled: false, + missingBins: const [], + missingEnv: const [], + missingConfig: const [], + ), + ) + .toList(growable: false) + : controller.skills.take(4).toList(growable: false); if (items.isEmpty) { return _PreviewEmptyState( message: - controller.connection.status == RuntimeConnectionStatus.connected + controller.isSingleAgentMode + ? (controller.currentSingleAgentNeedsAiGatewayConfiguration + ? appText( + '当前没有可用外部 CLI,请先配置 AI Gateway fallback。', + 'No external CLI is available. Configure AI Gateway fallback first.', + ) + : appText( + '当前线程还没有已加载技能。切换 provider 后会读取该线程自己的 skills 列表。', + 'No skills are loaded for this thread yet. Switching the provider reloads the thread-owned skills list.', + )) + : controller.connection.status == RuntimeConnectionStatus.connected ? appText( '当前代理没有已加载技能。', 'No skills are loaded for the active agent.', @@ -543,6 +572,9 @@ class _ClawHubFocusPreview extends StatelessWidget { @override Widget build(BuildContext context) { + final skillCount = controller.isSingleAgentMode + ? controller.currentAssistantSkillCount + : controller.skills.length; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -552,8 +584,8 @@ class _ClawHubFocusPreview extends StatelessWidget { children: [ _FocusPill( label: appText( - '已加载技能 ${controller.skills.length}', - 'Loaded skills ${controller.skills.length}', + '已加载技能 $skillCount', + 'Loaded skills $skillCount', ), ), _FocusPill( diff --git a/test/runtime/app_controller_ai_gateway_chat_suite.dart b/test/runtime/app_controller_ai_gateway_chat_suite.dart index ee3aa49b..37ce8866 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite.dart @@ -42,6 +42,7 @@ void main() { final gateway = _FakeGatewayRuntime(store: store); final controller = AppController( store: store, + availableSingleAgentProvidersOverride: const [], runtimeCoordinator: RuntimeCoordinator( gateway: gateway, codex: _FakeCodexRuntime(), @@ -60,6 +61,13 @@ void main() { selectedModels: const ['qwen2.5-coder:latest'], ), defaultModel: 'gpt-5.4', + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + mountTargets: _withAvailableMountTargets( + controller.settings.multiAgent.mountTargets, + const [], + ), + ), ), refreshAfterSave: false, ); @@ -103,6 +111,7 @@ void main() { final secondGateway = _FakeGatewayRuntime(store: secondStore); final secondController = AppController( store: secondStore, + availableSingleAgentProvidersOverride: const [], runtimeCoordinator: RuntimeCoordinator( gateway: secondGateway, codex: _FakeCodexRuntime(), @@ -158,7 +167,7 @@ void main() { expect(secondController.assistantConnectionStatusLabel, '单机智能体'); expect( secondController.assistantConnectionTargetLabel, - 'Auto · qwen2.5-coder:latest · 127.0.0.1:${server.port}', + 'AI Chat fallback · qwen2.5-coder:latest · 127.0.0.1:${server.port}', ); expect(secondController.chatMessages.last.text, 'SECOND_REPLY'); expect(gateway.connectedProfiles, isEmpty); @@ -190,6 +199,7 @@ void main() { ); final controller = AppController( store: store, + availableSingleAgentProvidersOverride: const [], runtimeCoordinator: RuntimeCoordinator( gateway: _FakeGatewayRuntime(store: store), codex: _FakeCodexRuntime(), @@ -208,6 +218,13 @@ void main() { selectedModels: const ['moonshotai/kimi-k2.5'], ), defaultModel: 'moonshotai/kimi-k2.5', + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + mountTargets: _withAvailableMountTargets( + controller.settings.multiAgent.mountTargets, + const [], + ), + ), ), refreshAfterSave: false, ); @@ -253,6 +270,7 @@ void main() { ); final controller = AppController( store: store, + availableSingleAgentProvidersOverride: const [], runtimeCoordinator: RuntimeCoordinator( gateway: _FakeGatewayRuntime(store: store), codex: _FakeCodexRuntime(), @@ -271,6 +289,13 @@ void main() { selectedModels: const ['z-ai/glm5'], ), defaultModel: 'z-ai/glm5', + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + mountTargets: _withAvailableMountTargets( + controller.settings.multiAgent.mountTargets, + const [], + ), + ), ), refreshAfterSave: false, ); @@ -331,6 +356,9 @@ void main() { ); final controller = AppController( store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], runtimeCoordinator: RuntimeCoordinator( gateway: _FakeGatewayRuntime(store: store), codex: _FakeCodexRuntime(), @@ -364,7 +392,86 @@ void main() { ); test( - 'AppController falls back to AI Chat when the selected Single Agent provider is unavailable', + 'AppController keeps the thread provider strict when another external CLI is available', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-single-agent-strict-provider-', + ); + final server = await _FakeAiGatewayServer.start( + responseMode: _AiGatewayResponseMode.json, + ); + addTearDown(() async { + await server.close(); + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final runner = _FakeSingleAgentRunner( + resolvedProvider: null, + fallbackReason: 'Codex CLI is unavailable on this device.', + ); + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.claude, + ], + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: store), + codex: _FakeCodexRuntime(), + ), + singleAgentRunner: runner, + ); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + await controller.settingsController.saveAiGatewayApiKey('live-key'); + await controller.saveSettings( + controller.settings.copyWith( + aiGateway: controller.settings.aiGateway.copyWith( + baseUrl: server.baseUrl, + availableModels: const ['moonshotai/kimi-k2.5'], + selectedModels: const ['moonshotai/kimi-k2.5'], + ), + defaultModel: 'moonshotai/kimi-k2.5', + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + mountTargets: _withAvailableMountTargets( + controller.settings.multiAgent.mountTargets, + const ['claude'], + ), + ), + ), + refreshAfterSave: false, + ); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + await controller.setSingleAgentProvider(SingleAgentProvider.codex); + + await controller.sendChatMessage('你好', thinking: 'low'); + + expect(runner.resolveCalls, 1); + expect(runner.runCalls, 0); + expect(server.requestCount, 0); + expect(controller.currentAssistantConnectionState.connected, isFalse); + expect( + controller.chatMessages.any( + (message) => message.text.contains('可切到 Auto'), + ), + isTrue, + ); + }, + ); + + test( + 'AppController falls back to AI Chat when no external CLI is available', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -391,6 +498,7 @@ void main() { ); final controller = AppController( store: store, + availableSingleAgentProvidersOverride: const [], runtimeCoordinator: RuntimeCoordinator( gateway: _FakeGatewayRuntime(store: store), codex: _FakeCodexRuntime(), @@ -542,6 +650,7 @@ class _FakeSingleAgentRunner implements SingleAgentRunner { int resolveCalls = 0; int runCalls = 0; + int abortCalls = 0; SingleAgentRunRequest? lastRequest; @override @@ -561,6 +670,9 @@ class _FakeSingleAgentRunner implements SingleAgentRunner { Future run(SingleAgentRunRequest request) async { runCalls += 1; lastRequest = request; + if (result?.output.isNotEmpty == true) { + request.onOutput?.call(result!.output); + } return result ?? SingleAgentRunResult( provider: request.provider, @@ -570,6 +682,11 @@ class _FakeSingleAgentRunner implements SingleAgentRunner { shouldFallbackToAiChat: false, ); } + + @override + Future abort(String sessionId) async { + abortCalls += 1; + } } class _FallbackOnlySingleAgentRunner extends _FakeSingleAgentRunner { @@ -692,6 +809,22 @@ class _FakeAiGatewayServer { enum _AiGatewayResponseMode { json, sse } +List _withAvailableMountTargets( + List current, + List availableIds, +) { + final nextIds = availableIds.toSet(); + return current + .map( + (item) => item.copyWith( + available: nextIds.contains(item.targetId), + discoveryState: nextIds.contains(item.targetId) ? 'ready' : 'idle', + syncState: nextIds.contains(item.targetId) ? 'ready' : 'idle', + ), + ) + .toList(growable: false); +} + Future _waitFor( bool Function() predicate, { Duration timeout = const Duration(seconds: 5), diff --git a/test/runtime/app_controller_ai_gateway_models_suite.dart b/test/runtime/app_controller_ai_gateway_models_suite.dart index b7750812..a8648ae9 100644 --- a/test/runtime/app_controller_ai_gateway_models_suite.dart +++ b/test/runtime/app_controller_ai_gateway_models_suite.dart @@ -20,11 +20,15 @@ void main() { ); addTearDown(() => tempDirectory.delete(recursive: true)); final store = _createIsolatedStore(tempDirectory.path); - final controller = AppController(store: store); + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [], + ); addTearDown(controller.dispose); addTearDown(store.dispose); await _waitFor(() => !controller.initializing); + await controller.settingsController.saveAiGatewayApiKey('live-key'); await controller.saveSettings( controller.settings.copyWith( @@ -53,7 +57,63 @@ void main() { ); addTearDown(() => tempDirectory.delete(recursive: true)); final store = _createIsolatedStore(tempDirectory.path); - final controller = AppController(store: store); + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [], + ); + addTearDown(controller.dispose); + addTearDown(store.dispose); + + await _waitFor(() => !controller.initializing); + await controller.settingsController.saveAiGatewayApiKey('live-key'); + + await controller.saveSettings( + controller.settings.copyWith( + aiGateway: controller.settings.aiGateway.copyWith( + baseUrl: 'http://127.0.0.1:11434/v1', + availableModels: const ['qwen2.5-coder:latest'], + selectedModels: const ['qwen2.5-coder:latest'], + ), + defaultModel: 'gpt-5.4', + assistantExecutionTarget: AssistantExecutionTarget.singleAgent, + ), + ); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + + expect(controller.assistantModelChoices, const [ + 'qwen2.5-coder:latest', + ]); + expect(controller.resolvedAssistantModel, 'qwen2.5-coder:latest'); + expect(controller.canUseAiGatewayConversation, isTrue); + + await controller.saveSettings( + controller.settings.copyWith( + assistantExecutionTarget: AssistantExecutionTarget.local, + ), + ); + + expect(controller.resolvedAssistantModel, 'gpt-5.4'); + expect(controller.assistantModelChoices, const ['gpt-5.4']); + }, + ); + + test( + 'AppController does not borrow AI Gateway model choices when an external Single Agent provider is available', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-app-controller-provider-models-', + ); + addTearDown(() => tempDirectory.delete(recursive: true)); + final store = _createIsolatedStore(tempDirectory.path); + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + ); addTearDown(controller.dispose); addTearDown(store.dispose); @@ -65,25 +125,19 @@ void main() { availableModels: const ['qwen2.5-coder:latest'], selectedModels: const ['qwen2.5-coder:latest'], ), - defaultModel: 'gpt-5.4', + defaultModel: 'qwen2.5-coder:latest', assistantExecutionTarget: AssistantExecutionTarget.singleAgent, ), ); - - expect(controller.assistantModelChoices, const [ - 'qwen2.5-coder:latest', - ]); - expect(controller.resolvedAssistantModel, 'qwen2.5-coder:latest'); - expect(controller.canUseAiGatewayConversation, isFalse); - - await controller.saveSettings( - controller.settings.copyWith( - assistantExecutionTarget: AssistantExecutionTarget.local, - ), + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, ); + await controller.setSingleAgentProvider(SingleAgentProvider.codex); - expect(controller.resolvedAssistantModel, 'gpt-5.4'); - expect(controller.assistantModelChoices, const ['gpt-5.4']); + expect(controller.currentSingleAgentHasResolvedProvider, isTrue); + expect(controller.currentSingleAgentUsesAiChatFallback, isFalse); + expect(controller.assistantModelChoices, isEmpty); + expect(controller.resolvedAssistantModel, isEmpty); }, ); } diff --git a/test/runtime/app_controller_execution_target_switch_suite.dart b/test/runtime/app_controller_execution_target_switch_suite.dart index 34f79001..abb06922 100644 --- a/test/runtime/app_controller_execution_target_switch_suite.dart +++ b/test/runtime/app_controller_execution_target_switch_suite.dart @@ -285,7 +285,7 @@ void main() { expect(controller.assistantConnectionStatusLabel, '单机智能体'); expect( controller.assistantConnectionTargetLabel, - 'Auto · qwen2.5-coder:latest · 127.0.0.1:11434', + '没有可用的外部 CLI,请配置 AI Gateway fallback。', ); expect( gateway.connectedProfiles, @@ -805,7 +805,7 @@ void main() { expect(controller.assistantConnectionStatusLabel, '单机智能体'); expect( controller.assistantConnectionTargetLabel, - 'Auto · qwen2.5-coder:latest · 127.0.0.1:11434', + '没有可用的外部 CLI,请配置 AI Gateway fallback。', ); }, ); diff --git a/test/runtime/app_controller_thread_skills_suite.dart b/test/runtime/app_controller_thread_skills_suite.dart index 0a531e4d..31184b06 100644 --- a/test/runtime/app_controller_thread_skills_suite.dart +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -11,7 +11,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { test( - 'AppController auto-discovers gateway-only skills into the available list without selecting them', + 'AppController loads Single Agent skills from the current thread provider roots', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -25,7 +25,7 @@ void main() { } }); final codexRoot = Directory('${tempDirectory.path}/codex-skills'); - final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills'); + final claudeRoot = Directory('${tempDirectory.path}/claude-skills'); await _writeSkill( codexRoot, 'idea-discovery', @@ -33,10 +33,10 @@ void main() { description: 'Discover ideas', ); await _writeSkill( - workbuddyRoot, - 'release-checks', - skillName: 'Release Checks', - description: 'Run release checks', + claudeRoot, + 'incident-review', + skillName: 'Incident Review', + description: 'Review incidents', ); final controller = AppController( @@ -46,18 +46,21 @@ void main() { '${tempDirectory.path}/settings.sqlite3', fallbackDirectoryPathResolver: () async => tempDirectory.path, ), + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + SingleAgentProvider.claude, + ], gatewayOnlySkillScanRoots: [ codexRoot.path, - codexRoot.path, - workbuddyRoot.path, + claudeRoot.path, ], ); addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); - await controller.setAssistantExecutionTarget( AssistantExecutionTarget.singleAgent, ); + await controller.setSingleAgentProvider(SingleAgentProvider.codex); expect( controller.assistantDiscoveredSkillsForSession( @@ -69,7 +72,14 @@ void main() { controller.assistantImportedSkillsForSession( controller.currentSessionKey, ), - hasLength(2), + hasLength(1), + ); + expect( + controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .single + .label, + 'Idea Discovery', ); expect( @@ -82,7 +92,7 @@ void main() { ); test( - 'AppController keeps imported skills and model choices isolated per thread', + 'AppController keeps provider-owned imported skills and model choices isolated per thread', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -96,12 +106,19 @@ void main() { } }); final codexRoot = Directory('${tempDirectory.path}/codex-skills'); + final claudeRoot = Directory('${tempDirectory.path}/claude-skills'); await _writeSkill( codexRoot, 'analysis', skillName: 'Analysis', description: 'Analyze tasks', ); + await _writeSkill( + claudeRoot, + 'review', + skillName: 'Review', + description: 'Review tasks', + ); final controller = AppController( store: SecureConfigStore( @@ -110,14 +127,18 @@ void main() { '${tempDirectory.path}/settings.sqlite3', fallbackDirectoryPathResolver: () async => tempDirectory.path, ), - gatewayOnlySkillScanRoots: [codexRoot.path], + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + SingleAgentProvider.claude, + ], + gatewayOnlySkillScanRoots: [codexRoot.path, claudeRoot.path], ); addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); - await controller.setAssistantExecutionTarget( AssistantExecutionTarget.singleAgent, ); + await controller.setSingleAgentProvider(SingleAgentProvider.codex); final firstSessionKey = controller.currentSessionKey; expect( controller.assistantImportedSkillsForSession(firstSessionKey), @@ -140,8 +161,15 @@ void main() { title: 'Thread 2', executionTarget: AssistantExecutionTarget.singleAgent, messageViewMode: AssistantMessageViewMode.rendered, + singleAgentProvider: SingleAgentProvider.claude, ); await controller.switchSession('draft:thread-2'); + expect( + controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ).single.label, + 'Review', + ); await controller.selectAssistantModelForSession( controller.currentSessionKey, 'model-b',