Refine single-agent thread scoped provider flow

This commit is contained in:
Haitao Pan 2026-03-23 12:54:20 +08:00
parent 2fbf7adc95
commit 2acd784b27
8 changed files with 840 additions and 134 deletions

View File

@ -47,6 +47,7 @@ class AppController extends ChangeNotifier {
DesktopPlatformService? desktopPlatformService,
UiFeatureManifest? uiFeatureManifest,
List<String>? gatewayOnlySkillScanRoots,
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
SingleAgentRunner? singleAgentRunner,
}) {
_store = store ?? SecureConfigStore();
@ -92,6 +93,8 @@ class AppController extends ChangeNotifier {
(_isFlutterTestEnvironment
? const <String>[]
: _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<String> _gatewayOnlySkillScanRoots;
late final List<SingleAgentProvider>? _availableSingleAgentProvidersOverride;
late final ArisBundleRepository _arisBundleRepository;
late final ArisBridgeLocator _arisBridgeLocator;
late final MultiAgentMountManager _multiAgentMountManager;
@ -150,6 +154,7 @@ class AppController extends ChangeNotifier {
<String, HttpClient>{};
final Set<String> _aiGatewayPendingSessionKeys = <String>{};
final Set<String> _aiGatewayAbortedSessionKeys = <String>{};
final Set<String> _singleAgentExternalCliPendingSessionKeys = <String>{};
final Set<String> _activeMultiAgentBrokerSessions = <String>{};
bool _multiAgentRunPending = false;
int _localMessageCounter = 0;
@ -309,7 +314,20 @@ class AppController extends ChangeNotifier {
hasStoredAiGatewayApiKey &&
resolvedAiGatewayModel.isNotEmpty;
List<SingleAgentProvider> 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<String> 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 <AssistantThreadSkillEntry>[];
}
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<String> 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<SingleAgentProvider> 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(<String>[
provider.label,
model,
host,
]);
final providerReady = _canUseSingleAgentProvider(provider);
final providerReady = resolvedProvider != null;
final detail = providerReady
? _joinConnectionParts(<String>[
resolvedProvider.label,
model,
])
: fallbackReady
? _joinConnectionParts(<String>[
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<String> _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 <String>[selectedModel!];
}
return const <String>[];
}
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 <AssistantThreadSkillEntry>[],
importedSkills: const <AssistantThreadSkillEntry>[],
selectedSkillKeys: const <String>[],
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 <AssistantThreadSkillEntry>[],
@ -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<List<AssistantThreadSkillEntry>>
_scanGatewayOnlySkillCandidates() async {
Future<List<AssistantThreadSkillEntry>> _scanGatewayOnlySkillCandidatesForSession(
String sessionKey,
) async {
final provider = singleAgentResolvedProviderForSession(sessionKey);
if (provider == null) {
return const <AssistantThreadSkillEntry>[];
}
final home = Platform.environment['HOME']?.trim() ?? '';
if (home.isEmpty &&
_gatewayOnlySkillScanRoots.every((item) => !item.startsWith('/'))) {
@ -3571,6 +3872,12 @@ class AppController extends ChangeNotifier {
final entries = <AssistantThreadSkillEntry>[];
final seen = <String>{};
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<AssistantThreadSkillEntry> _skillEntryFromFile(
File file,
String rootPath,

View File

@ -459,7 +459,9 @@ class _AssistantPageState extends State<AssistantPage> {
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,

View File

@ -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<SingleAgentRunResult> run(SingleAgentRunRequest request);
Future<void> abort(String sessionId);
}
class DefaultSingleAgentRunner implements SingleAgentRunner {
@ -94,6 +102,8 @@ class DefaultSingleAgentRunner implements SingleAgentRunner {
final Future<bool> Function(String command)? _binaryExistsResolver;
final CliProcessStarter _processStarter;
final Map<String, Process> _activeProcesses = <String, Process>{};
final Set<String> _abortedSessionIds = <String>{};
@override
Future<SingleAgentProviderResolution> 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<void>();
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<void>();
final exitCode = await process.exitCode.timeout(timeout, onTimeout: () {
try {
process.kill(ProcessSignal.sigkill);
} catch (_) {
// Best effort only.
}
return -1;
});
await Future.wait<void>(<Future<void>>[
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<void> 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.
}
}

View File

@ -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 <String>[],
missingEnv: const <String>[],
missingConfig: const <String>[],
),
)
.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(

View File

@ -42,6 +42,7 @@ void main() {
final gateway = _FakeGatewayRuntime(store: store);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
runtimeCoordinator: RuntimeCoordinator(
gateway: gateway,
codex: _FakeCodexRuntime(),
@ -60,6 +61,13 @@ void main() {
selectedModels: const <String>['qwen2.5-coder:latest'],
),
defaultModel: 'gpt-5.4',
multiAgent: controller.settings.multiAgent.copyWith(
autoSync: false,
mountTargets: _withAvailableMountTargets(
controller.settings.multiAgent.mountTargets,
const <String>[],
),
),
),
refreshAfterSave: false,
);
@ -103,6 +111,7 @@ void main() {
final secondGateway = _FakeGatewayRuntime(store: secondStore);
final secondController = AppController(
store: secondStore,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
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 <SingleAgentProvider>[],
runtimeCoordinator: RuntimeCoordinator(
gateway: _FakeGatewayRuntime(store: store),
codex: _FakeCodexRuntime(),
@ -208,6 +218,13 @@ void main() {
selectedModels: const <String>['moonshotai/kimi-k2.5'],
),
defaultModel: 'moonshotai/kimi-k2.5',
multiAgent: controller.settings.multiAgent.copyWith(
autoSync: false,
mountTargets: _withAvailableMountTargets(
controller.settings.multiAgent.mountTargets,
const <String>[],
),
),
),
refreshAfterSave: false,
);
@ -253,6 +270,7 @@ void main() {
);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
runtimeCoordinator: RuntimeCoordinator(
gateway: _FakeGatewayRuntime(store: store),
codex: _FakeCodexRuntime(),
@ -271,6 +289,13 @@ void main() {
selectedModels: const <String>['z-ai/glm5'],
),
defaultModel: 'z-ai/glm5',
multiAgent: controller.settings.multiAgent.copyWith(
autoSync: false,
mountTargets: _withAvailableMountTargets(
controller.settings.multiAgent.mountTargets,
const <String>[],
),
),
),
refreshAfterSave: false,
);
@ -331,6 +356,9 @@ void main() {
);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
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(<String, Object>{});
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>[
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 <String>['moonshotai/kimi-k2.5'],
selectedModels: const <String>['moonshotai/kimi-k2.5'],
),
defaultModel: 'moonshotai/kimi-k2.5',
multiAgent: controller.settings.multiAgent.copyWith(
autoSync: false,
mountTargets: _withAvailableMountTargets(
controller.settings.multiAgent.mountTargets,
const <String>['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(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -391,6 +498,7 @@ void main() {
);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
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<SingleAgentRunResult> 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<void> abort(String sessionId) async {
abortCalls += 1;
}
}
class _FallbackOnlySingleAgentRunner extends _FakeSingleAgentRunner {
@ -692,6 +809,22 @@ class _FakeAiGatewayServer {
enum _AiGatewayResponseMode { json, sse }
List<ManagedMountTargetState> _withAvailableMountTargets(
List<ManagedMountTargetState> current,
List<String> 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<void> _waitFor(
bool Function() predicate, {
Duration timeout = const Duration(seconds: 5),

View File

@ -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 <SingleAgentProvider>[],
);
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 <SingleAgentProvider>[],
);
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 <String>['qwen2.5-coder:latest'],
selectedModels: const <String>['qwen2.5-coder:latest'],
),
defaultModel: 'gpt-5.4',
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
),
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
expect(controller.assistantModelChoices, const <String>[
'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 <String>['gpt-5.4']);
},
);
test(
'AppController does not borrow AI Gateway model choices when an external Single Agent provider is available',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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>[
SingleAgentProvider.codex,
],
);
addTearDown(controller.dispose);
addTearDown(store.dispose);
@ -65,25 +125,19 @@ void main() {
availableModels: const <String>['qwen2.5-coder:latest'],
selectedModels: const <String>['qwen2.5-coder:latest'],
),
defaultModel: 'gpt-5.4',
defaultModel: 'qwen2.5-coder:latest',
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
),
);
expect(controller.assistantModelChoices, const <String>[
'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 <String>['gpt-5.4']);
expect(controller.currentSingleAgentHasResolvedProvider, isTrue);
expect(controller.currentSingleAgentUsesAiChatFallback, isFalse);
expect(controller.assistantModelChoices, isEmpty);
expect(controller.resolvedAssistantModel, isEmpty);
},
);
}

View File

@ -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。',
);
},
);

View File

@ -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(<String, Object>{});
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>[
SingleAgentProvider.codex,
SingleAgentProvider.claude,
],
gatewayOnlySkillScanRoots: <String>[
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(<String, Object>{});
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: <String>[codexRoot.path],
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.claude,
],
gatewayOnlySkillScanRoots: <String>[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',