Merge branch 'codex/bridge-provider-unify'

This commit is contained in:
Haitao Pan 2026-04-10 14:20:57 +08:00
commit ee452fc7ea
8 changed files with 253 additions and 275 deletions

View File

@ -295,6 +295,8 @@ class AppController extends ChangeNotifier {
Map<SingleAgentProvider, SingleAgentCapabilities>
singleAgentCapabilitiesByProviderInternal =
const <SingleAgentProvider, SingleAgentCapabilities>{};
List<SingleAgentProvider> bridgeAdvertisedProvidersInternal =
const <SingleAgentProvider>[];
final Map<String, List<GatewayChatMessage>> assistantThreadMessagesInternal =
<String, List<GatewayChatMessage>>{};
late final DesktopTaskThreadRepository taskThreadRepositoryInternal =
@ -306,7 +308,8 @@ class AppController extends ChangeNotifier {
final Map<String, String> aiGatewayStreamingTextBySessionInternal =
<String, String>{};
final Map<String, ExternalCodeAgentAcpSyncedProvider>
syncedGoAgentProvidersInternal = <String, ExternalCodeAgentAcpSyncedProvider>{};
syncedGoAgentProvidersInternal =
<String, ExternalCodeAgentAcpSyncedProvider>{};
final DesktopThreadArtifactService threadArtifactServiceInternal =
DesktopThreadArtifactService();
List<AssistantThreadSkillEntry> singleAgentSharedImportedSkillsInternal =
@ -576,7 +579,7 @@ class AppController extends ChangeNotifier {
List<SingleAgentProvider> get configuredSingleAgentProviders =>
normalizeSingleAgentProviderList(
(availableSingleAgentProvidersOverrideInternal ??
settings.savedSingleAgentProviders)
bridgeAdvertisedProvidersInternal)
.where((item) => item != SingleAgentProvider.auto)
.map(settings.resolveSingleAgentProvider),
);
@ -599,7 +602,9 @@ class AppController extends ChangeNotifier {
}
return <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
...visible.where((target) => target != AssistantExecutionTarget.singleAgent),
...visible.where(
(target) => target != AssistantExecutionTarget.singleAgent,
),
];
}
@ -697,7 +702,7 @@ class AppController extends ChangeNotifier {
const <AssistantThreadSkillEntry>[];
}
// Keep legacy public APIs as class members for cross-library callers.
// Keep these public navigation APIs as class members for cross-library callers.
void navigateTo(WorkspaceDestination destination) =>
AppControllerDesktopNavigation(this).navigateTo(destination);

View File

@ -89,12 +89,20 @@ Future<void> refreshSingleAgentCapabilitiesRuntimeInternal(
target: AssistantExecutionTarget.singleAgent,
forceRefresh: forceRefresh,
);
controller.bridgeAdvertisedProvidersInternal =
controller.availableSingleAgentProvidersOverrideInternal != null
? normalizeSingleAgentProviderList(
controller.availableSingleAgentProvidersOverrideInternal!,
)
: normalizeSingleAgentProviderList(
capabilities.providers.map(
controller.settings.resolveSingleAgentProvider,
),
);
final next = <SingleAgentProvider, SingleAgentCapabilities>{};
for (final provider in controller.configuredSingleAgentProviders) {
if (!capabilities.providers.contains(provider)) {
next[provider] = const SingleAgentCapabilities.unavailable(
endpoint: '',
);
next[provider] = const SingleAgentCapabilities.unavailable(endpoint: '');
continue;
}
next[provider] = SingleAgentCapabilities(

View File

@ -62,7 +62,18 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
target: AssistantExecutionTarget.singleAgent,
forceRefresh: true,
);
final availableProviders = controller.configuredSingleAgentProviders
final advertisedProviders =
controller.availableSingleAgentProvidersOverrideInternal != null
? normalizeSingleAgentProviderList(
controller.availableSingleAgentProvidersOverrideInternal!,
)
: normalizeSingleAgentProviderList(
capabilities.providers.map(
controller.settings.resolveSingleAgentProvider,
),
);
controller.bridgeAdvertisedProvidersInternal = advertisedProviders;
final availableProviders = advertisedProviders
.where(capabilities.providers.contains)
.toList(growable: false);
final provider = selection == SingleAgentProvider.auto

View File

@ -478,10 +478,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText(
'基础连接配置',
'Base Connection Configuration',
),
appText('基础连接配置', 'Base Connection Configuration'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
@ -538,8 +535,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
runSpacing: 12,
children: [
StatusChipInternal(
label:
'${appText('默认连接来源', 'Default Source')}: $currentSource',
label: '${appText('默认连接来源', 'Default Source')}: $currentSource',
tone: StatusChipToneInternal.ready,
),
StatusChipInternal(
@ -741,8 +737,8 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
children: [
Text(
appText(
'这里保留 Codex、OpenCode 作为内建接入。更多 Provider 请通过向导新增自定义 ACP Server Endpoint历史上真正配置过的 Claude / Gemini 会迁移为自定义条目,空白旧预设会自动清理',
'Codex and OpenCode stay here as built-in integrations. Add more providers through the custom ACP endpoint wizard; configured legacy Claude and Gemini entries are migrated into custom entries, while empty legacy presets are cleaned up automatically.',
'这里仅管理 Bridge 侧 catalog 的同步定义与认证信息。助手里的 Provider 列表完全以 Bridge 返回的 capabilities 为准,本页配置不会直接决定下拉里显示什么',
'This section only manages sync definitions and credentials for the Bridge-side catalog. The provider list in Assistant comes entirely from Bridge capabilities; editing settings here does not directly populate the picker.',
),
style: theme.textTheme.bodyMedium,
),
@ -757,7 +753,9 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
settings,
),
icon: const Icon(Icons.add_rounded),
label: Text(appText('添加更多自定义配置', 'Add more custom configurations')),
label: Text(
appText('添加 Bridge 同步配置', 'Add Bridge sync definition'),
),
),
),
const SizedBox(height: 16),

View File

@ -581,42 +581,14 @@ class SettingsSnapshot {
if (resolved.isAuto) {
return SingleAgentProvider.auto;
}
for (final saved in savedSingleAgentProviders) {
if (saved.providerId == resolved.providerId) {
return saved;
}
}
if (kKnownSingleAgentProviders.any(
(item) => item.providerId == resolved.providerId,
)) {
return resolved;
}
if (savedSingleAgentProviders.isNotEmpty) {
return savedSingleAgentProviders.first;
}
return SingleAgentProvider.auto;
return resolved;
}
List<SingleAgentProvider> get availableSingleAgentProviders =>
normalizeSingleAgentProviderList(
externalAcpEndpoints.map((item) => item.toProvider()),
);
List<SingleAgentProvider> get savedSingleAgentProviders =>
normalizeSingleAgentProviderList(
externalAcpEndpoints.map((item) {
final provider = item.toProvider();
if (provider.isAuto) {
return null;
}
final effective = externalAcpEndpointForProvider(provider);
if (!effective.enabled || effective.endpoint.trim().isEmpty) {
return null;
}
return effective.toProvider();
}).whereType<SingleAgentProvider>(),
);
bool isGatewayTargetSaved(AssistantExecutionTarget target) {
final targetKey = switch (target) {
AssistantExecutionTarget.local => 'local',
@ -640,19 +612,6 @@ class SettingsSnapshot {
);
}
List<SingleAgentProvider> visibleSingleAgentProviders(
Iterable<SingleAgentProvider> availableProviders,
) {
final allowedProviderIds = savedSingleAgentProviders
.map((item) => item.providerId)
.toSet();
return normalizeSingleAgentProviderList(
availableProviders.where(
(item) => allowedProviderIds.contains(item.providerId),
),
);
}
List<AssistantExecutionTarget> visibleAssistantExecutionTargets({
required Iterable<AssistantExecutionTarget> supportedTargets,
required Iterable<SingleAgentProvider> availableSingleAgentProviders,
@ -660,7 +619,7 @@ class SettingsSnapshot {
final supported = supportedTargets.toSet();
final visible = <AssistantExecutionTarget>[];
if (supported.contains(AssistantExecutionTarget.singleAgent) &&
visibleSingleAgentProviders(availableSingleAgentProviders).isNotEmpty) {
availableSingleAgentProviders.isNotEmpty) {
visible.add(AssistantExecutionTarget.singleAgent);
}
if (supported.contains(AssistantExecutionTarget.local) &&

View File

@ -7,9 +7,11 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/app/app_controller_desktop_external_acp_routing.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import '../test_support.dart';
import 'app_controller_ai_gateway_chat_suite_fakes.dart';
void main() {
group('ACP bridge provider hub', () {
@ -33,10 +35,6 @@ void main() {
.endpoint,
'https://bridge.example.com',
);
expect(
snapshot.savedSingleAgentProviders.map((item) => item.providerId),
contains('opencode'),
);
},
);
@ -88,6 +86,86 @@ void main() {
);
},
);
test('single-agent picker follows bridge capabilities only', () async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore(enableSecureStorage: false);
final controller = AppController(
store: store,
goTaskServiceClient: FakeGoTaskServiceClientInternal(
capabilities: ExternalCodeAgentAcpCapabilities(
singleAgent: true,
multiAgent: false,
providers: <SingleAgentProvider>{
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
},
raw: <String, dynamic>{},
),
),
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.refreshSingleAgentCapabilitiesInternal(
forceRefresh: true,
);
expect(
controller.singleAgentProviderOptions
.map((item) => item.providerId)
.toList(growable: false),
const <String>['codex', 'opencode'],
);
});
test(
'local sync-only custom provider does not appear unless bridge advertises it',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore(enableSecureStorage: false);
final controller = AppController(
store: store,
goTaskServiceClient: FakeGoTaskServiceClientInternal(
capabilities: ExternalCodeAgentAcpCapabilities(
singleAgent: true,
multiAgent: false,
providers: <SingleAgentProvider>{SingleAgentProvider.opencode},
raw: <String, dynamic>{},
),
),
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.saveSettings(
controller.settings.copyWith(
externalAcpEndpoints: normalizeExternalAcpEndpoints(
profiles: <ExternalAcpEndpointProfile>[
...controller.settings.externalAcpEndpoints,
buildCustomExternalAcpEndpointProfile(
controller.settings.externalAcpEndpoints,
label: 'Lab Agent',
endpoint: 'wss://lab.example.com/acp',
),
],
),
),
refreshAfterSave: false,
);
await controller.refreshSingleAgentCapabilitiesInternal(
forceRefresh: true,
);
expect(
controller.singleAgentProviderOptions
.map((item) => item.providerId)
.toList(growable: false),
const <String>['opencode'],
);
},
);
});
}

View File

@ -43,17 +43,16 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = await createAppControllerInternal(
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.opencode,
],
runtimeCoordinator: RuntimeCoordinator(
gateway: FakeGatewayRuntimeInternal(store: store),
codex: FakeCodexRuntimeInternal(),
),
goTaskServiceClient: client,
);
addTearDown(controller.dispose);
await waitForInternal(() => !controller.initializing);
await controller.saveSettings(
controller.settings.copyWith(
multiAgent: controller.settings.multiAgent.copyWith(
@ -99,6 +98,68 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
},
);
test(
'AppController resolves auto Single Agent provider from the current bridge capabilities order',
() async {
final tempDirectory = await createTempDirectoryInternal(
'xworkmate-single-agent-auto-bridge-order-',
);
final store = createStoreFromTempDirectoryInternal(tempDirectory);
final client = FakeGoTaskServiceClientInternal(
capabilities: ExternalCodeAgentAcpCapabilities(
singleAgent: true,
multiAgent: false,
providers: <SingleAgentProvider>{
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
},
raw: <String, dynamic>{},
),
result: const GoTaskServiceResult(
success: true,
message: 'AUTO_PROVIDER_REPLY',
turnId: 'turn-auto-provider',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: 'codex-sonnet',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = AppController(
store: store,
runtimeCoordinator: RuntimeCoordinator(
gateway: FakeGatewayRuntimeInternal(store: store),
codex: FakeCodexRuntimeInternal(),
),
goTaskServiceClient: client,
);
addTearDown(controller.dispose);
await waitForInternal(() => !controller.initializing);
await controller.saveSettings(
controller.settings.copyWith(
workspacePath: tempDirectory.path,
multiAgent: controller.settings.multiAgent.copyWith(
autoSync: false,
),
),
refreshAfterSave: false,
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.setSingleAgentProvider(SingleAgentProvider.auto);
await controller.sendChatMessage(
'请输出 AUTO_PROVIDER_REPLY',
thinking: 'low',
);
expect(client.executeCalls, 1);
expect(client.lastRequest?.provider, SingleAgentProvider.codex);
},
);
test(
'AppController syncs custom single-agent providers before execution',
() async {
@ -187,97 +248,66 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
);
test(
'AppController drops stale custom-agent thread bindings and starts new single-agent tasks with the canonical provider',
'AppController keeps persisted Single Agent bindings but reports them unavailable when the bridge stops advertising that provider',
() async {
final tempDirectory = await createTempDirectoryInternal(
'xworkmate-single-agent-stale-provider-',
'xworkmate-single-agent-bridge-unavailable-provider-',
);
final store = createStoreFromTempDirectoryInternal(tempDirectory);
final client = FakeGoTaskServiceClientInternal(
capabilities: ExternalCodeAgentAcpCapabilities(
singleAgent: true,
multiAgent: false,
providers: <SingleAgentProvider>{SingleAgentProvider.codex},
providers: <SingleAgentProvider>{SingleAgentProvider.opencode},
raw: <String, dynamic>{},
),
result: const GoTaskServiceResult(
success: true,
message: 'CANONICAL_CODEX_REPLY',
turnId: 'turn-canonical',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: 'codex-sonnet',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = await createAppControllerInternal(
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
runtimeCoordinator: RuntimeCoordinator(
gateway: FakeGatewayRuntimeInternal(store: store),
codex: FakeCodexRuntimeInternal(),
),
goTaskServiceClient: client,
);
addTearDown(controller.dispose);
await waitForInternal(() => !controller.initializing);
await controller.saveSettings(
controller.settings.copyWith(
externalAcpEndpoints: normalizeExternalAcpEndpoints(
profiles: <ExternalAcpEndpointProfile>[
...controller.settings.externalAcpEndpoints,
ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider.codex,
).copyWith(endpoint: 'ws://127.0.0.1:9102/acp'),
],
),
workspacePath: tempDirectory.path,
multiAgent: controller.settings.multiAgent.copyWith(
autoSync: false,
),
),
refreshAfterSave: false,
);
controller.upsertTaskThreadInternal(
'main',
singleAgentProvider: const SingleAgentProvider(
providerId: 'custom-agent-1',
label: 'Codex',
badge: 'C',
),
singleAgentProviderSource: ThreadSelectionSource.explicit,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
expect(controller.currentSingleAgentProvider.providerId, 'codex');
controller.initializeAssistantThreadContext(
'draft:new-single-agent-thread',
title: 'New conversation',
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: controller.currentAssistantMessageViewMode,
singleAgentProvider: controller.currentSingleAgentProvider,
);
await controller.switchSession('draft:new-single-agent-thread');
expect(controller.currentSingleAgentProvider.providerId, 'codex');
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.sendChatMessage(
'请输出 CANONICAL_CODEX_REPLY',
thinking: 'low',
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
expect(
controller.currentSingleAgentProvider,
SingleAgentProvider.codex,
);
expect(client.lastRequest?.provider, SingleAgentProvider.codex);
await controller.sendChatMessage('你好', thinking: 'low');
expect(client.capabilitiesCalls, greaterThanOrEqualTo(1));
expect(client.executeCalls, 0);
expect(
client.syncedProvidersHistory.any(
(batch) => batch.any(
(provider) =>
provider.providerId == 'codex' &&
provider.endpoint == 'ws://127.0.0.1:9102/acp',
),
controller.currentSingleAgentProvider,
SingleAgentProvider.codex,
);
expect(
controller.chatMessages.any(
(message) =>
message.role == 'assistant' &&
(message.text.contains('当前 GoTaskService 不支持 Codex') ||
message.text.contains(
'GoTaskService does not currently support Codex',
)),
),
isTrue,
);
@ -327,7 +357,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
controller.currentAssistantConnectionState.executionTarget,
AssistantExecutionTarget.singleAgent,
);
expect(controller.currentAssistantConnectionState.connected, isFalse);
expect(controller.currentAssistantConnectionState.connected, isTrue);
expect(controller.currentAssistantConnectionState.ready, isTrue);
expect(
controller.currentAssistantConnectionState.detailLabel,
@ -539,72 +569,6 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
},
);
test(
'AppController keeps the thread provider strict when another external CLI is available',
() async {
final tempDirectory = await createTempDirectoryInternal(
'xworkmate-single-agent-strict-provider-',
);
final server = await FakeAiGatewayServerInternal.start(
responseMode: AiGatewayResponseModeInternal.json,
);
addTearDown(() async {
await server.close();
});
final store = createStoreFromTempDirectoryInternal(tempDirectory);
final client = FallbackOnlyGoTaskServiceClientInternal();
final controller = await createAppControllerInternal(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.claude,
],
runtimeCoordinator: RuntimeCoordinator(
gateway: FakeGatewayRuntimeInternal(store: store),
codex: FakeCodexRuntimeInternal(),
),
goTaskServiceClient: client,
);
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: withAvailableMountTargetsInternal(
controller.settings.multiAgent.mountTargets,
const <String>['claude'],
),
),
),
refreshAfterSave: false,
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
await controller.sendChatMessage('你好', thinking: 'low');
expect(client.capabilitiesCalls, greaterThanOrEqualTo(1));
expect(client.executeCalls, 0);
expect(server.requestCount, 0);
expect(controller.currentAssistantConnectionState.connected, isTrue);
expect(
controller.chatMessages.any(
(message) => message.text.contains('可切到可用的 ACP Server'),
),
isTrue,
);
},
);
test(
'AppController returns an ACP-only error when no provider is available',
() async {

View File

@ -223,96 +223,51 @@ void main() {
);
test(
'available single-agent providers follow normalized endpoint settings',
'visible execution targets depend on runtime-supplied providers, not local provider presets',
() {
final snapshot = SettingsSnapshot.defaults().copyWith(
externalAcpEndpoints: normalizeExternalAcpEndpoints(
profiles: <ExternalAcpEndpointProfile>[
...SettingsSnapshot.defaults().externalAcpEndpoints,
buildCustomExternalAcpEndpointProfile(
SettingsSnapshot.defaults().externalAcpEndpoints,
label: 'Lab Agent',
endpoint: 'wss://lab.example.com/acp',
),
const ExternalAcpEndpointProfile(
providerKey: 'claude',
label: 'Claude',
badge: 'Cl',
endpoint: '',
authRef: '',
enabled: true,
final defaults = SettingsSnapshot.defaults();
final snapshot = defaults
.copyWith(
externalAcpEndpoints: normalizeExternalAcpEndpoints(
profiles: <ExternalAcpEndpointProfile>[
...defaults.externalAcpEndpoints,
ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider.codex,
).copyWith(endpoint: 'wss://codex.example.com/acp'),
],
),
)
.markGatewayTargetSaved(AssistantExecutionTarget.remote);
expect(
snapshot.visibleAssistantExecutionTargets(
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
],
availableSingleAgentProviders: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
),
const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.remote,
],
);
expect(
snapshot.availableSingleAgentProviders
.map((item) => item.label)
.toList(),
const <String>['OpenCode', 'Lab Agent'],
snapshot.visibleAssistantExecutionTargets(
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
],
availableSingleAgentProviders: const <SingleAgentProvider>[],
),
const <AssistantExecutionTarget>[AssistantExecutionTarget.remote],
);
},
);
test('saved single-agent providers require a non-empty saved endpoint', () {
final defaults = SettingsSnapshot.defaults();
final snapshot = defaults.copyWith(
externalAcpEndpoints: normalizeExternalAcpEndpoints(
profiles: <ExternalAcpEndpointProfile>[
...defaults.externalAcpEndpoints,
ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider.codex,
).copyWith(endpoint: 'wss://codex.example.com/acp'),
const ExternalAcpEndpointProfile(
providerKey: 'custom-agent-2',
label: 'Empty Agent',
badge: 'EA',
endpoint: '',
authRef: '',
enabled: true,
),
],
),
);
expect(
snapshot.savedSingleAgentProviders
.map((item) => item.label)
.toList(growable: false),
const <String>['Codex'],
);
});
test('visible execution targets only include explicitly saved targets', () {
final defaults = SettingsSnapshot.defaults();
final snapshot = defaults
.copyWith(
externalAcpEndpoints: normalizeExternalAcpEndpoints(
profiles: <ExternalAcpEndpointProfile>[
...defaults.externalAcpEndpoints,
ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider.codex,
).copyWith(endpoint: 'wss://codex.example.com/acp'),
],
),
)
.markGatewayTargetSaved(AssistantExecutionTarget.remote);
expect(
snapshot.visibleAssistantExecutionTargets(
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
],
availableSingleAgentProviders: snapshot.availableSingleAgentProviders,
),
const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.remote,
],
);
});
});
}