Refactor single-agent provider planning

This commit is contained in:
Haitao Pan 2026-03-27 17:38:15 +08:00
parent 58961153a7
commit 00b07afa12
11 changed files with 321 additions and 340 deletions

View File

@ -454,11 +454,12 @@ class AppController extends ChangeNotifier {
_settingsController.storedGatewayPasswordMaskForProfile(profileIndex);
List<SingleAgentProvider> get configuredSingleAgentProviders =>
(_availableSingleAgentProvidersOverride ??
settings.availableSingleAgentProviders)
.where((item) => item != SingleAgentProvider.auto)
.map(settings.resolveSingleAgentProvider)
.toList(growable: false);
normalizeSingleAgentProviderList(
(_availableSingleAgentProvidersOverride ??
settings.availableSingleAgentProviders)
.where((item) => item != SingleAgentProvider.auto)
.map(settings.resolveSingleAgentProvider),
);
List<SingleAgentProvider> get availableSingleAgentProviders =>
configuredSingleAgentProviders
@ -2623,16 +2624,6 @@ class AppController extends ChangeNotifier {
await _refreshAcpCapabilities(forceRefresh: true);
await _refreshSingleAgentCapabilities(forceRefresh: true);
final runtimeMode = effectiveCodeAgentRuntimeMode;
if (runtimeMode == CodeAgentRuntimeMode.externalCli &&
!_canUseSingleAgentProvider(SingleAgentProvider.codex)) {
throw StateError(
appText(
'外部 single-agent endpoint 未报告 Codex 可用,请先检查 app-server / Gateway 配置。',
'The external single-agent endpoint did not report Codex availability. Check the app-server or Gateway endpoint first.',
),
);
}
await _runtimeCoordinator.configureCodexForGateway(
gatewayUrl: gatewayUrl,
@ -4220,18 +4211,26 @@ class AppController extends ChangeNotifier {
}
SettingsSnapshot _sanitizeCodeAgentSettings(SettingsSnapshot snapshot) {
final normalizedRuntimeMode =
snapshot.codeAgentRuntimeMode == CodeAgentRuntimeMode.builtIn
? CodeAgentRuntimeMode.externalCli
: snapshot.codeAgentRuntimeMode;
_codexRuntimeWarning =
snapshot.codeAgentRuntimeMode == CodeAgentRuntimeMode.builtIn
? appText(
'内置 Codex 仍处于实验阶段;建议优先使用 External Codex CLI。',
'Built-in Codex is still experimental; External Codex CLI is recommended.',
'内置 Codex 运行时当前仅保留为未来扩展位;已自动切换为 External Codex CLI。',
'Built-in Codex runtime is reserved for a future release; XWorkmate switched back to External Codex CLI automatically.',
)
: null;
final normalizedPath = snapshot.codexCliPath.trim();
if (normalizedPath == snapshot.codexCliPath) {
if (normalizedPath == snapshot.codexCliPath &&
normalizedRuntimeMode == snapshot.codeAgentRuntimeMode) {
return snapshot;
}
return snapshot.copyWith(codexCliPath: normalizedPath);
return snapshot.copyWith(
codeAgentRuntimeMode: normalizedRuntimeMode,
codexCliPath: normalizedPath,
);
}
Future<void> _refreshAcpCapabilities({
@ -4386,14 +4385,13 @@ class AppController extends ChangeNotifier {
}
void _registerCodexExternalProvider() {
final endpoint = _resolveSingleAgentEndpoint(SingleAgentProvider.codex);
_runtimeCoordinator.registerExternalCodeAgent(
ExternalCodeAgentProvider(
id: 'codex',
name: 'Codex ACP',
command: 'xworkmate-agent-gateway',
transport: ExternalAgentTransport.websocketJsonRpc,
endpoint: endpoint?.toString() ?? '',
endpoint: '',
defaultArgs: const <String>[],
capabilities: const <String>[
'chat',

View File

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../i18n/app_language.dart';
import '../../runtime/platform_environment.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
class CodexIntegrationCard extends StatefulWidget {
@ -51,9 +50,6 @@ class _CodexIntegrationCardState extends State<CodexIntegrationCard> {
Widget build(BuildContext context) {
final palette = context.palette;
final controller = widget.controller;
final selectedRuntimeMode = controller.configuredCodeAgentRuntimeMode;
final isExternalMode =
selectedRuntimeMode == CodeAgentRuntimeMode.externalCli;
final cooperationLabel = switch (controller.codexCooperationState) {
CodexCooperationState.notStarted => appText('未启动', 'Not started'),
CodexCooperationState.bridgeOnly => appText(
@ -65,9 +61,7 @@ class _CodexIntegrationCardState extends State<CodexIntegrationCard> {
'Started and registered to the gateway',
),
};
final binaryLabel = !isExternalMode
? appText('不需要', 'Not required')
: controller.hasDetectedCodexCli
final binaryLabel = controller.hasDetectedCodexCli
? appText('已就绪', 'Ready')
: appText('未检测到', 'Not found');
final bridgeLabel = controller.isCodexBridgeEnabled
@ -99,62 +93,22 @@ class _CodexIntegrationCardState extends State<CodexIntegrationCard> {
const SizedBox(height: 12),
Text(
appText(
'显式启用桥接后XWorkmate 会使用外部 Codex CLI 进程,并在 Gateway 已连接时注册为协同 code-agent bridge。',
'When enabled, XWorkmate launches an external Codex CLI process and registers as a cooperative code-agent bridge if the gateway is connected.',
'XWorkmate 当前通过外部 Codex CLI 进程提供桥接能力;启用后会在 Gateway 已连接时注册为协同 code-agent bridge。',
'XWorkmate currently exposes bridge capabilities through an external Codex CLI process. When enabled, it registers as a cooperative code-agent bridge if the gateway is connected.',
),
style: TextStyle(fontSize: 13, color: palette.textSecondary),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: Text(
appText('External Codex CLI', 'External Codex CLI'),
),
selected:
selectedRuntimeMode == CodeAgentRuntimeMode.externalCli,
onSelected: controller.isCodexBridgeBusy
? null
: (selected) => selected
? _setRuntimeMode(CodeAgentRuntimeMode.externalCli)
: null,
),
ChoiceChip(
label: Text(
appText(
'Built-in Codex (Experimental)',
'Built-in Codex (Experimental)',
),
),
selected: selectedRuntimeMode == CodeAgentRuntimeMode.builtIn,
onSelected: controller.isCodexBridgeBusy
? null
: (selected) => selected
? _setRuntimeMode(CodeAgentRuntimeMode.builtIn)
: null,
),
],
),
const SizedBox(height: 16),
_StatusRow(
label: appText('运行时模式', 'Runtime mode'),
value: controller.effectiveCodeAgentRuntimeMode.label,
value: appText('外部 Codex CLI', 'External Codex CLI'),
),
_StatusRow(
label: appText('Binary 状态', 'Binary status'),
value: binaryLabel,
detail: !isExternalMode
? appText(
'Built-in 运行时不依赖外部 codex 可执行文件。',
'Built-in runtime does not require an external codex binary.',
)
: controller.resolvedCodexCliPath ??
appText(
'请安装 codex 或填写路径。',
'Install codex or set a path.',
),
detail:
controller.resolvedCodexCliPath ??
appText('请安装 codex 或填写路径。', 'Install codex or set a path.'),
),
_StatusRow(
label: appText('Bridge 状态', 'Bridge status'),
@ -182,7 +136,7 @@ class _CodexIntegrationCardState extends State<CodexIntegrationCard> {
),
onSubmitted: (_) => _savePathOverride(),
),
if (isExternalMode && !controller.hasDetectedCodexCli) ...[
if (!controller.hasDetectedCodexCli) ...[
const SizedBox(height: 8),
Text(
appText(
@ -276,36 +230,6 @@ class _CodexIntegrationCardState extends State<CodexIntegrationCard> {
);
}
Future<void> _setRuntimeMode(CodeAgentRuntimeMode mode) async {
if (widget.controller.isCodexBridgeEnabled) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
appText(
'请先停用 Bridge 再切换运行时模式。',
'Disable the bridge before switching runtime mode.',
),
),
),
);
return;
}
await widget.controller.saveSettings(
widget.controller.settings.copyWith(codeAgentRuntimeMode: mode),
refreshAfterSave: false,
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(appText('运行时模式已更新。', 'Runtime mode updated.'))),
);
}
Future<void> _savePathOverride() async {
final trimmed = _pathController.text.trim();
await widget.controller.saveSettings(

View File

@ -156,7 +156,7 @@ class SingleAgentProvider {
required this.providerId,
required this.label,
required this.badge,
this.preset = false,
this.source = SingleAgentProviderSource.externalExtension,
});
static const SingleAgentProvider auto = SingleAgentProvider(
@ -169,14 +169,13 @@ class SingleAgentProvider {
providerId: 'codex',
label: 'Codex',
badge: 'C',
preset: true,
source: SingleAgentProviderSource.builtInReserved,
);
static const SingleAgentProvider opencode = SingleAgentProvider(
providerId: 'opencode',
label: 'OpenCode',
badge: 'O',
preset: true,
);
static const SingleAgentProvider claude = SingleAgentProvider(
@ -194,15 +193,19 @@ class SingleAgentProvider {
final String providerId;
final String label;
final String badge;
final bool preset;
final SingleAgentProviderSource source;
bool get isAuto => providerId == auto.providerId;
bool get isBuiltInReserved =>
source == SingleAgentProviderSource.builtInReserved;
bool get isExternalExtension =>
source == SingleAgentProviderSource.externalExtension;
SingleAgentProvider copyWith({
String? providerId,
String? label,
String? badge,
bool? preset,
SingleAgentProviderSource? source,
}) {
final resolvedProviderId = normalizeSingleAgentProviderId(
providerId ?? this.providerId,
@ -220,7 +223,7 @@ class SingleAgentProvider {
label: resolvedLabel,
)
: resolvedBadge,
preset: preset ?? this.preset,
source: source ?? this.source,
);
}
@ -265,11 +268,33 @@ extension SingleAgentProviderCopy on SingleAgentProvider {
}) => SingleAgentProvider.fromJsonValue(value, label: label, badge: badge);
}
const List<SingleAgentProvider> kBuiltinExternalAcpProviders =
<SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
];
enum SingleAgentProviderSource { externalExtension, builtInReserved }
SingleAgentProvider normalizeSingleAgentProviderSelection(
SingleAgentProvider provider,
) {
if (provider.isBuiltInReserved) {
return SingleAgentProvider.opencode;
}
return provider;
}
List<SingleAgentProvider> normalizeSingleAgentProviderList(
Iterable<SingleAgentProvider> providers,
) {
final normalized = <SingleAgentProvider>[];
final seen = <String>{};
for (final provider in providers) {
final resolved = normalizeSingleAgentProviderSelection(provider);
if (seen.add(resolved.providerId)) {
normalized.add(resolved);
}
}
return normalized;
}
const List<SingleAgentProvider> kPresetExternalAcpProviders =
<SingleAgentProvider>[SingleAgentProvider.opencode];
const List<SingleAgentProvider> kKnownSingleAgentProviders =
<SingleAgentProvider>[
@ -279,7 +304,11 @@ const List<SingleAgentProvider> kKnownSingleAgentProviders =
SingleAgentProvider.gemini,
];
const Set<String> kLegacyExternalAcpProviderIds = <String>{'claude', 'gemini'};
const Set<String> kLegacyExternalAcpProviderIds = <String>{
'claude',
'gemini',
'codex',
};
class ExternalAcpEndpointProfile {
const ExternalAcpEndpointProfile({
@ -336,16 +365,18 @@ class ExternalAcpEndpointProfile {
return null;
}
bool get isPreset => kBuiltinExternalAcpProviders.any(
(item) => item.providerId == providerKey,
);
bool get isPreset =>
kPresetExternalAcpProviders.any((item) => item.providerId == providerKey);
SingleAgentProvider toProvider() {
final builtin = builtinProvider;
return SingleAgentProvider.fromJsonValue(
providerKey,
label: label,
badge: badge,
).copyWith(preset: isPreset);
).copyWith(
source: builtin?.source ?? SingleAgentProviderSource.externalExtension,
);
}
Map<String, dynamic> toJson() {
@ -435,7 +466,7 @@ List<ExternalAcpEndpointProfile> normalizeExternalAcpEndpoints({
}
final normalized = <ExternalAcpEndpointProfile>[
for (final provider in kBuiltinExternalAcpProviders)
for (final provider in kPresetExternalAcpProviders)
byKey.remove(provider.providerId) ??
ExternalAcpEndpointProfile.defaultsForProvider(provider),
...migratedCustomProfiles,
@ -2086,10 +2117,8 @@ class SettingsSnapshot {
ExternalAcpEndpointProfile externalAcpEndpointForProvider(
SingleAgentProvider provider,
) {
return externalAcpEndpoints.firstWhere(
(item) => item.providerKey.trim().toLowerCase() == provider.providerId,
orElse: () => ExternalAcpEndpointProfile.defaultsForProvider(provider),
);
return externalAcpEndpointForProviderId(provider.providerId) ??
ExternalAcpEndpointProfile.defaultsForProvider(provider);
}
ExternalAcpEndpointProfile? externalAcpEndpointForProviderId(
@ -2104,18 +2133,31 @@ class SettingsSnapshot {
return item;
}
}
if (kLegacyExternalAcpProviderIds.contains(normalized)) {
final canonical = SingleAgentProvider.fromJsonValue(normalized);
for (final item in externalAcpEndpoints) {
if (!item.isPreset &&
item.label.trim() == canonical.label &&
item.badge.trim() == canonical.badge) {
return item;
}
}
}
return null;
}
SingleAgentProvider resolveSingleAgentProvider(SingleAgentProvider provider) {
if (provider.isAuto) {
final normalizedSelection = normalizeSingleAgentProviderSelection(provider);
if (normalizedSelection.isAuto) {
return SingleAgentProvider.auto;
}
final profile = externalAcpEndpointForProviderId(provider.providerId);
final profile = externalAcpEndpointForProviderId(
normalizedSelection.providerId,
);
if (profile != null) {
return profile.toProvider();
}
return provider;
return normalizedSelection;
}
SingleAgentProvider singleAgentProviderForId(String providerId) {
@ -2123,17 +2165,22 @@ class SettingsSnapshot {
if (resolved.isEmpty || resolved == SingleAgentProvider.auto.providerId) {
return SingleAgentProvider.auto;
}
final profile = externalAcpEndpointForProviderId(resolved);
final normalizedSelection = normalizeSingleAgentProviderSelection(
SingleAgentProvider.fromJsonValue(resolved),
);
final profile = externalAcpEndpointForProviderId(
normalizedSelection.providerId,
);
if (profile != null) {
return profile.toProvider();
}
return SingleAgentProvider.fromJsonValue(resolved);
return normalizedSelection;
}
List<SingleAgentProvider> get availableSingleAgentProviders =>
externalAcpEndpoints
.map((item) => item.toProvider())
.toList(growable: false);
normalizeSingleAgentProviderList(
externalAcpEndpoints.map((item) => item.toProvider()),
);
SettingsSnapshot copyWithExternalAcpEndpointForProvider(
SingleAgentProvider provider,

View File

@ -170,21 +170,9 @@ void main() {
);
await tester.pump();
expect(find.text('External Codex CLI'), findsOneWidget);
expect(find.text('Built-in Codex (Experimental)'), findsOneWidget);
expect(find.text('外部 Codex CLI'), findsOneWidget);
expect(find.text('未检测到'), findsOneWidget);
final builtInChip = find.widgetWithText(
ChoiceChip,
'Built-in Codex (Experimental)',
);
await tester.ensureVisible(builtInChip);
await tester.tap(builtInChip);
await tester.pumpAndSettle();
expect(
controller.settings.codeAgentRuntimeMode,
CodeAgentRuntimeMode.builtIn,
);
expect(find.byType(ChoiceChip), findsNothing);
late Directory tempDir;
late File codexBinary;

View File

@ -342,9 +342,9 @@ void main() {
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
final runner = _FakeSingleAgentRunner(
resolvedProvider: SingleAgentProvider.codex,
resolvedProvider: SingleAgentProvider.opencode,
result: const SingleAgentRunResult(
provider: SingleAgentProvider.codex,
provider: SingleAgentProvider.opencode,
output: 'CODEX_REPLY',
success: true,
errorMessage: '',
@ -355,7 +355,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
runtimeCoordinator: RuntimeCoordinator(
gateway: _FakeGatewayRuntime(store: store),
@ -369,13 +369,13 @@ void main() {
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
await controller.sendChatMessage('请输出 CODEX_REPLY', thinking: 'low');
expect(runner.resolveCalls, 1);
expect(runner.runCalls, 1);
expect(runner.lastRequest?.provider, SingleAgentProvider.codex);
expect(runner.lastRequest?.provider, SingleAgentProvider.opencode);
expect(runner.lastRequest?.model, isEmpty);
expect(controller.currentSingleAgentModelDisplayLabel, 'codex-sonnet');
expect(
@ -394,7 +394,9 @@ void main() {
isFalse,
);
expect(
controller.chatMessages.any((message) => message.toolName == 'Codex'),
controller.chatMessages.any(
(message) => message.toolName == 'OpenCode',
),
isFalse,
);
},
@ -419,9 +421,9 @@ void main() {
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
final runner = _FakeSingleAgentRunner(
resolvedProvider: SingleAgentProvider.codex,
resolvedProvider: SingleAgentProvider.opencode,
result: const SingleAgentRunResult(
provider: SingleAgentProvider.codex,
provider: SingleAgentProvider.opencode,
output: 'CODEX_REPLY',
success: true,
errorMessage: '',
@ -432,7 +434,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
runtimeCoordinator: RuntimeCoordinator(
gateway: _FakeGatewayRuntime(store: store),
@ -450,14 +452,14 @@ void main() {
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
await controller.sendChatMessage('请输出 CODEX_REPLY', thinking: 'low');
expect(
controller.chatMessages.any(
(message) =>
message.toolName == 'Codex' &&
message.toolName == 'OpenCode' &&
(message.text.contains('单机智能体已切换到') ||
message.text.contains('Single Agent is using')),
),
@ -528,7 +530,7 @@ void main() {
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
await controller.sendChatMessage('你好', thinking: 'low');
@ -598,7 +600,7 @@ void main() {
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
await controller.sendChatMessage('你好', thinking: 'low');
@ -675,9 +677,9 @@ void main() {
]);
final runner = _FakeSingleAgentRunner(
resolvedProvider: SingleAgentProvider.codex,
resolvedProvider: SingleAgentProvider.opencode,
result: const SingleAgentRunResult(
provider: SingleAgentProvider.codex,
provider: SingleAgentProvider.opencode,
output: 'THREAD_OK',
success: true,
errorMessage: '',
@ -687,7 +689,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
runtimeCoordinator: RuntimeCoordinator(
gateway: _FakeGatewayRuntime(store: store),
@ -740,9 +742,9 @@ void main() {
);
final runner = _FakeSingleAgentRunner(
resolvedProvider: SingleAgentProvider.codex,
resolvedProvider: SingleAgentProvider.opencode,
result: const SingleAgentRunResult(
provider: SingleAgentProvider.codex,
provider: SingleAgentProvider.opencode,
output: 'THREAD_OK',
success: true,
errorMessage: '',
@ -752,7 +754,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
runtimeCoordinator: RuntimeCoordinator(
gateway: _FakeGatewayRuntime(store: store),

View File

@ -111,7 +111,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
);
addTearDown(controller.dispose);
@ -132,7 +132,7 @@ void main() {
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
expect(controller.currentSingleAgentHasResolvedProvider, isTrue);
expect(controller.currentSingleAgentUsesAiChatFallback, isFalse);
@ -154,7 +154,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.opencode,
],
);

View File

@ -251,7 +251,7 @@ void main() {
);
test(
'AppController preserves built-in mode and does not require external codex binary',
'AppController sanitizes legacy built-in mode back to external cli',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
@ -277,7 +277,7 @@ void main() {
expect(
controller.settings.codeAgentRuntimeMode,
CodeAgentRuntimeMode.builtIn,
CodeAgentRuntimeMode.externalCli,
);
expect(controller.codexRuntimeWarning, isNotNull);
@ -289,7 +289,7 @@ void main() {
CodexCooperationState.bridgeOnly,
);
expect(codex.startCalled, isFalse);
expect(coordinator.runtimeMode, CodeAgentRuntimeMode.builtIn);
expect(coordinator.runtimeMode, CodeAgentRuntimeMode.externalCli);
},
);
}, skip: _manualCodexBridgeSkipReason);

View File

@ -65,7 +65,7 @@ void main() {
final controller = AppController(
store: await _createStore(tempDirectory.path),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.claude,
],
singleAgentSharedSkillScanRootOverrides: <String>[
@ -80,7 +80,7 @@ void main() {
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
await _waitFor(
() =>
controller
@ -165,7 +165,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
singleAgentSharedSkillScanRootOverrides: const <String>[],
);
@ -241,7 +241,7 @@ void main() {
final controller = AppController(
store: await _createStore(tempDirectory.path),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
singleAgentSharedSkillScanRootOverrides: <String>[sharedRoot.path],
);
@ -294,7 +294,7 @@ void main() {
userHomeDirectory: userHome.path,
),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
singleAgentSharedSkillScanRootOverrides: const <String>[
'~/.agents/skills',
@ -366,7 +366,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
singleAgentSharedSkillScanRootOverrides: <String>[fixedRoot.path],
);
@ -443,7 +443,7 @@ void main() {
return AppController(
store: await createStore(),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.claude,
],
singleAgentSharedSkillScanRootOverrides: <String>[
@ -642,7 +642,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
singleAgentSharedSkillScanRootOverrides: const <String>[],
);
@ -734,7 +734,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
singleAgentSharedSkillScanRootOverrides: <String>[customRoot.path],
);
@ -821,7 +821,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
singleAgentSharedSkillScanRootOverrides: const <String>[],
);
@ -933,7 +933,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
singleAgentSharedSkillScanRootOverrides: <String>[customRoot.path],
);
@ -1035,7 +1035,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
singleAgentSharedSkillScanRootOverrides: <String>[customRoot.path],
);
@ -1109,7 +1109,7 @@ void main() {
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
singleAgentSharedSkillScanRootOverrides: const <String>[],
);
@ -1295,11 +1295,11 @@ class _AcpSkillsStatusServer {
'result': <String, dynamic>{
'singleAgent': true,
'multiAgent': true,
'providers': const <String>['codex'],
'providers': const <String>['opencode'],
'capabilities': <String, dynamic>{
'single_agent': true,
'multi_agent': true,
'providers': const <String>['codex'],
'providers': const <String>['opencode'],
},
},
});

View File

@ -38,7 +38,7 @@ void main() {
);
});
test('probes websocket endpoint and reports codex support', () async {
test('probes websocket endpoint and reports provider support', () async {
final server = await _FakeAppServer.start();
addTearDown(server.close);
@ -47,11 +47,14 @@ void main() {
);
final capabilities = await client.loadCapabilities(
provider: SingleAgentProvider.codex,
provider: SingleAgentProvider.opencode,
);
expect(capabilities.available, isTrue);
expect(capabilities.supportsCodex, isTrue);
expect(
capabilities.supportsProvider(SingleAgentProvider.opencode),
isTrue,
);
expect(capabilities.endpoint, 'ws://127.0.0.1:${server.port}');
expect(server.methods, contains('initialize'));
});
@ -69,7 +72,7 @@ void main() {
final result = await client.run(
const DirectSingleAgentRunRequest(
sessionId: 'session-1',
provider: SingleAgentProvider.codex,
provider: SingleAgentProvider.opencode,
prompt: 'hello world',
model: 'gpt-4.1',
workingDirectory: '/tmp',
@ -103,7 +106,7 @@ void main() {
final result = await client.run(
DirectSingleAgentRunRequest(
sessionId: 'session-skills',
provider: SingleAgentProvider.codex,
provider: SingleAgentProvider.opencode,
prompt: 'use the selected skills',
model: 'gpt-4.1',
workingDirectory: '/tmp',
@ -159,7 +162,7 @@ void main() {
final runFuture = client.run(
const DirectSingleAgentRunRequest(
sessionId: 'session-abort',
provider: SingleAgentProvider.codex,
provider: SingleAgentProvider.opencode,
prompt: 'abort me',
model: 'gpt-4.1',
workingDirectory: '/tmp',
@ -189,7 +192,7 @@ void main() {
final result = await client.run(
const DirectSingleAgentRunRequest(
sessionId: 'session-nested',
provider: SingleAgentProvider.codex,
provider: SingleAgentProvider.opencode,
prompt: 'hello nested world',
model: 'qwen2.5-coder:latest',
workingDirectory: '/tmp',
@ -203,25 +206,28 @@ void main() {
},
);
test('probes OpenCode REST endpoint and reports provider support', () async {
final server = await _FakeOpenCodeRestServer.start();
addTearDown(server.close);
test(
'probes OpenCode REST endpoint and reports provider support',
() async {
final server = await _FakeOpenCodeRestServer.start();
addTearDown(server.close);
final client = DirectSingleAgentAppServerClient(
endpointResolver: (_) => server.baseHttpUri,
);
final client = DirectSingleAgentAppServerClient(
endpointResolver: (_) => server.baseHttpUri,
);
final capabilities = await client.loadCapabilities(
provider: SingleAgentProvider.opencode,
);
final capabilities = await client.loadCapabilities(
provider: SingleAgentProvider.opencode,
);
expect(capabilities.available, isTrue);
expect(
capabilities.supportsProvider(SingleAgentProvider.opencode),
isTrue,
);
expect(server.healthRequested, isTrue);
});
expect(capabilities.available, isTrue);
expect(
capabilities.supportsProvider(SingleAgentProvider.opencode),
isTrue,
);
expect(server.healthRequested, isTrue);
},
);
test('runs OpenCode turns over REST session api', () async {
final server = await _FakeOpenCodeRestServer.start();
@ -547,9 +553,14 @@ class _FakeOpenCodeRestServer {
HttpHeaders.contentTypeHeader,
'text/event-stream',
);
request.response.headers.set(HttpHeaders.cacheControlHeader, 'no-cache');
request.response.headers.set(
HttpHeaders.cacheControlHeader,
'no-cache',
);
request.response.write(
'data: ${jsonEncode(<String, dynamic>{'payload': <String, dynamic>{'type': 'server.connected', 'properties': <String, dynamic>{}}})}\n\n',
'data: ${jsonEncode(<String, dynamic>{
'payload': <String, dynamic>{'type': 'server.connected', 'properties': <String, dynamic>{}},
})}\n\n',
);
await request.response.flush();
_eventResponses.add(request.response);
@ -564,15 +575,16 @@ class _FakeOpenCodeRestServer {
'id': sessionId,
'title': 'test',
'directory':
request.uri.queryParameters['directory'] ?? Directory.current.path,
request.uri.queryParameters['directory'] ??
Directory.current.path,
}),
);
await request.response.close();
continue;
}
final sessionMatch = RegExp(r'^/session/([^/]+)/message$').firstMatch(
request.uri.path,
);
final sessionMatch = RegExp(
r'^/session/([^/]+)/message$',
).firstMatch(request.uri.path);
if (sessionMatch != null && request.method == 'GET') {
final sessionId = sessionMatch.group(1)!;
final text = _assistantTextBySession[sessionId] ?? '';
@ -603,87 +615,78 @@ class _FakeOpenCodeRestServer {
if (sessionMatch != null && request.method == 'POST') {
final sessionId = sessionMatch.group(1)!;
final body = jsonDecode(await utf8.decodeStream(request));
final parts = (body as Map<String, dynamic>)['parts'] as List<dynamic>? ??
final parts =
(body as Map<String, dynamic>)['parts'] as List<dynamic>? ??
const <dynamic>[];
if (parts.isNotEmpty) {
lastPromptText =
(parts.first as Map<String, dynamic>)['text']?.toString() ?? '';
}
final assistantMessageId = 'msg-assistant-${_messageCounter++}';
await _broadcastEvent(
<String, dynamic>{
'payload': <String, dynamic>{
'type': 'session.status',
'properties': <String, dynamic>{
'sessionID': sessionId,
'status': <String, dynamic>{'type': 'busy'},
await _broadcastEvent(<String, dynamic>{
'payload': <String, dynamic>{
'type': 'session.status',
'properties': <String, dynamic>{
'sessionID': sessionId,
'status': <String, dynamic>{'type': 'busy'},
},
},
});
await _broadcastEvent(<String, dynamic>{
'payload': <String, dynamic>{
'type': 'message.updated',
'properties': <String, dynamic>{
'sessionID': sessionId,
'info': <String, dynamic>{
'id': assistantMessageId,
'role': 'assistant',
},
},
},
);
await _broadcastEvent(
<String, dynamic>{
'payload': <String, dynamic>{
'type': 'message.updated',
'properties': <String, dynamic>{
'sessionID': sessionId,
'info': <String, dynamic>{
'id': assistantMessageId,
'role': 'assistant',
},
},
},
},
);
});
for (final delta in <String>['hello ', 'world ', 'from ', 'opencode']) {
await _broadcastEvent(
<String, dynamic>{
'payload': <String, dynamic>{
'type': 'message.part.delta',
'properties': <String, dynamic>{
'sessionID': sessionId,
'part': <String, dynamic>{'messageID': assistantMessageId},
'text': delta,
},
await _broadcastEvent(<String, dynamic>{
'payload': <String, dynamic>{
'type': 'message.part.delta',
'properties': <String, dynamic>{
'sessionID': sessionId,
'part': <String, dynamic>{'messageID': assistantMessageId},
'text': delta,
},
},
);
});
}
await _broadcastEvent(
<String, dynamic>{
'payload': <String, dynamic>{
'type': 'message.part.updated',
'properties': <String, dynamic>{
'sessionID': sessionId,
'part': <String, dynamic>{
'messageID': assistantMessageId,
'type': 'text',
'text': 'hello world from opencode',
},
await _broadcastEvent(<String, dynamic>{
'payload': <String, dynamic>{
'type': 'message.part.updated',
'properties': <String, dynamic>{
'sessionID': sessionId,
'part': <String, dynamic>{
'messageID': assistantMessageId,
'type': 'text',
'text': 'hello world from opencode',
},
},
},
);
});
_assistantTextBySession[sessionId] = 'hello world from opencode';
await _broadcastEvent(
<String, dynamic>{
'payload': <String, dynamic>{
'type': 'session.status',
'properties': <String, dynamic>{
'sessionID': sessionId,
'status': <String, dynamic>{'type': 'idle'},
},
await _broadcastEvent(<String, dynamic>{
'payload': <String, dynamic>{
'type': 'session.status',
'properties': <String, dynamic>{
'sessionID': sessionId,
'status': <String, dynamic>{'type': 'idle'},
},
},
);
});
request.response.headers.contentType = ContentType.json;
request.response.write('');
await request.response.close();
continue;
}
final abortMatch = RegExp(r'^/session/([^/]+)/abort$').firstMatch(
request.uri.path,
);
final abortMatch = RegExp(
r'^/session/([^/]+)/abort$',
).firstMatch(request.uri.path);
if (abortMatch != null && request.method == 'POST') {
request.response.headers.contentType = ContentType.json;
request.response.write('{}');

View File

@ -11,57 +11,64 @@ void main() {
expect(
snapshot.externalAcpEndpoints
.take(2)
.take(1)
.map((item) => item.providerKey)
.toList(growable: false),
const <String>['codex', 'opencode'],
const <String>['opencode'],
);
});
test('round-trip preserves built-in entries and custom extensions', () {
final snapshot = SettingsSnapshot.defaults().copyWith(
externalAcpEndpoints: normalizeExternalAcpEndpoints(
profiles: <ExternalAcpEndpointProfile>[
ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider.codex,
).copyWith(endpoint: 'ws://127.0.0.1:9001'),
ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider.opencode,
).copyWith(endpoint: 'https://opencode.example.com'),
const ExternalAcpEndpointProfile(
providerKey: 'custom-lab',
label: 'Custom Lab',
badge: 'CL',
endpoint: 'wss://lab.example.com/acp',
enabled: true,
),
],
),
);
test(
'round-trip preserves migrated legacy entries and custom extensions',
() {
final snapshot = SettingsSnapshot.defaults().copyWith(
externalAcpEndpoints: normalizeExternalAcpEndpoints(
profiles: <ExternalAcpEndpointProfile>[
ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider.codex,
).copyWith(endpoint: 'ws://127.0.0.1:9001'),
ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider.opencode,
).copyWith(endpoint: 'https://opencode.example.com'),
const ExternalAcpEndpointProfile(
providerKey: 'custom-lab',
label: 'Custom Lab',
badge: 'CL',
endpoint: 'wss://lab.example.com/acp',
enabled: true,
),
],
),
);
final decoded = SettingsSnapshot.fromJson(snapshot.toJson());
final decoded = SettingsSnapshot.fromJson(snapshot.toJson());
expect(
decoded
.externalAcpEndpointForProvider(SingleAgentProvider.codex)
.endpoint,
'ws://127.0.0.1:9001',
);
expect(
decoded
.externalAcpEndpointForProvider(SingleAgentProvider.opencode)
.endpoint,
'https://opencode.example.com',
);
expect(
decoded.externalAcpEndpoints.any(
(item) =>
item.providerKey == 'custom-lab' &&
item.endpoint == 'wss://lab.example.com/acp',
),
isTrue,
);
});
expect(
decoded.externalAcpEndpoints.any(
(item) =>
item.label == 'Codex' &&
item.endpoint == 'ws://127.0.0.1:9001' &&
item.providerKey.startsWith('custom-agent-'),
),
isTrue,
);
expect(
decoded
.externalAcpEndpointForProvider(SingleAgentProvider.opencode)
.endpoint,
'https://opencode.example.com',
);
expect(decoded.externalAcpEndpointForProviderId('codex'), isNull);
expect(
decoded.externalAcpEndpoints.any(
(item) =>
item.providerKey == 'custom-lab' &&
item.endpoint == 'wss://lab.example.com/acp',
),
isTrue,
);
},
);
test('empty legacy claude and gemini entries are dropped', () {
final normalized = normalizeExternalAcpEndpoints(
@ -84,8 +91,8 @@ void main() {
);
expect(
normalized.take(2).map((item) => item.providerKey).toList(),
const <String>['codex', 'opencode'],
normalized.take(1).map((item) => item.providerKey).toList(),
const <String>['opencode'],
);
expect(
normalized.where(
@ -153,7 +160,7 @@ void main() {
expect(
normalized.map((item) => item.providerKey).toList(growable: false),
const <String>['codex', 'opencode'],
const <String>['opencode'],
);
});
@ -166,8 +173,8 @@ void main() {
endpoint: 'wss://lab.example.com/acp',
);
expect(profile.providerKey, 'custom-agent-3');
expect(profile.label, 'Custom ACP Endpoint 3');
expect(profile.providerKey, 'custom-agent-2');
expect(profile.label, 'Custom ACP Endpoint 2');
expect(profile.endpoint, 'wss://lab.example.com/acp');
},
);
@ -199,7 +206,7 @@ void main() {
snapshot.availableSingleAgentProviders
.map((item) => item.label)
.toList(),
const <String>['Codex', 'OpenCode', 'Lab Agent'],
const <String>['OpenCode', 'Lab Agent'],
);
},
);

View File

@ -47,7 +47,7 @@ void main() {
);
final threadSingle = controller.currentSessionKey;
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
await controller.setAssistantMessageViewMode(
AssistantMessageViewMode.raw,
);
@ -101,7 +101,7 @@ void main() {
);
expect(
controller.singleAgentProviderForSession(threadSingle),
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
);
expect(
controller.assistantMessageViewModeForSession(threadSingle),
@ -150,7 +150,7 @@ void main() {
);
expect(
reloaded.singleAgentProviderForSession(threadSingle),
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
);
expect(
reloaded.assistantMessageViewModeForSession(threadSingle),
@ -232,12 +232,12 @@ void main() {
];
final fakeAcp = _FakeAcpClient(
skillCatalog: <String, List<Map<String, dynamic>>>{
'codex': <Map<String, dynamic>>[
'opencode': <Map<String, dynamic>>[
<String, dynamic>{
'skillKey': 'codex-skill',
'name': 'Codex Skill',
'description': 'Codex-owned skill',
'source': 'codex',
'name': 'OpenCode Skill',
'description': 'OpenCode-owned skill',
'source': 'opencode',
},
],
'claude': <Map<String, dynamic>>[
@ -284,12 +284,24 @@ void main() {
);
await controller.applySettingsDraft();
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
final claudeProvider = controller.singleAgentProviderOptions.singleWhere(
(item) => item.label == 'Claude',
);
fakeAcp._skillCatalog[claudeProvider.providerId] = <Map<String, dynamic>>[
<String, dynamic>{
'skillKey': 'claude-skill',
'name': 'Claude Skill',
'description': 'Claude-owned skill',
'source': 'claude',
},
];
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.map((item) => item.label),
contains('Codex Skill'),
contains('OpenCode Skill'),
);
await controller.toggleAssistantSkillForSession(
controller.currentSessionKey,
@ -302,7 +314,7 @@ void main() {
hasLength(1),
);
await controller.setSingleAgentProvider(SingleAgentProvider.claude);
await controller.setSingleAgentProvider(claudeProvider);
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
@ -335,12 +347,12 @@ void main() {
final fakeAcp = _FakeAcpClient(
skillCatalog: <String, List<Map<String, dynamic>>>{
'codex': <Map<String, dynamic>>[
'opencode': <Map<String, dynamic>>[
<String, dynamic>{
'skillKey': 'codex-skill',
'name': 'Codex Skill',
'description': 'Codex-owned skill',
'source': 'codex',
'name': 'OpenCode Skill',
'description': 'OpenCode-owned skill',
'source': 'opencode',
},
],
},
@ -361,7 +373,7 @@ void main() {
token: '',
password: '',
);
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
await controller.setSingleAgentProvider(SingleAgentProvider.opencode);
await controller.toggleAssistantSkillForSession(
controller.currentSessionKey,
'codex-skill',
@ -507,7 +519,7 @@ class _FakeAcpClient extends WebAcpClient {
singleAgent: true,
multiAgent: true,
providers: <SingleAgentProvider>{
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.opencode,
SingleAgentProvider.claude,
SingleAgentProvider.gemini,