Refine single-agent thread scoped provider flow
This commit is contained in:
parent
2fbf7adc95
commit
2acd784b27
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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。',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user