Refactor single-agent provider planning
This commit is contained in:
parent
58961153a7
commit
00b07afa12
@ -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',
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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('{}');
|
||||
|
||||
@ -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'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user