feat(runtime): add built-in/external codex modes and external agent provider registry
This commit is contained in:
parent
c062ed1661
commit
6c12439168
@ -1,6 +1,18 @@
|
||||
# Codex CLI 集成任务计划 - 已完成
|
||||
|
||||
> 目标:将 Codex CLI 集成到 XWorkmate 作为内置 Code Agent,支持 AI Gateway 模型桥接和 OpenClaw Gateway 在线/离线模式。
|
||||
## 目标(产品层)
|
||||
|
||||
1. 将 Codex CLI 集成到 XWorkmate 作为内置 Code Agent,支持 AI Gateway 模型桥接和 OpenClaw Gateway 在线/离线模式。
|
||||
2. 提供可选设置:将 Codex CLI 以**外部依赖**方式接入 XWorkmate,支持同一套网关桥接与模式切换能力。
|
||||
3. 预留其他外部 Code Agent CLI 的接入能力(统一注册、能力发现与调度)。
|
||||
|
||||
## 当前实现状态(对齐当前代码)
|
||||
|
||||
- 当前落地形态为**外部进程接入**:由 `CodexRuntime.startStdio()` 启动外部 `codex` 进程。
|
||||
- Codex 可执行文件通过 `findCodexBinary()` 从 `CODEX_PATH`、常见安装目录与 `PATH` 中查找。
|
||||
- 用户需预先安装 Codex CLI(例如 `npm i -g @openai/codex`)。
|
||||
- 运行时由 Dart `Process`(`_process: Process?`)进行生命周期管理。
|
||||
- 通过 `AgentRegistry`、`RuntimeCoordinator` 已具备多 Agent 扩展的基础结构,可继续接入其他外部 CLI。
|
||||
|
||||
## 架构概览
|
||||
|
||||
@ -33,8 +45,8 @@
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw Gateway │
|
||||
│ .env: AI-Gateway-Url = https://api.svc.plus/v1 │
|
||||
│ .env: AI-Gateway-apiKey = ***REMOVED-CREDENTIAL*** │
|
||||
│ .env: AI-Gateway-Url = <development/test only> │
|
||||
│ .env: AI-Gateway-apiKey = <do not commit real secrets> │
|
||||
│ │
|
||||
│ 模式切换: │
|
||||
│ - Local: 127.0.0.1:18789 (本地代理) │
|
||||
|
||||
@ -3,11 +3,11 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'gateway_runtime.dart';
|
||||
import 'runtime_models.dart';
|
||||
import 'codex_runtime.dart';
|
||||
import 'codex_config_bridge.dart';
|
||||
import 'codex_runtime.dart';
|
||||
import 'gateway_runtime.dart';
|
||||
import 'mode_switcher.dart';
|
||||
import 'runtime_models.dart';
|
||||
|
||||
/// Coordination state for the runtime.
|
||||
enum CoordinatorState {
|
||||
@ -18,56 +18,129 @@ enum CoordinatorState {
|
||||
error,
|
||||
}
|
||||
|
||||
/// Unified runtime coordinator for managing Gateway and Codex.
|
||||
///
|
||||
/// Code agent runtime mode for Codex integration.
|
||||
///
|
||||
/// - [builtIn]: XWorkmate internal runtime path (no external codex process).
|
||||
/// - [externalCli]: Launch external `codex` executable via stdio bridge.
|
||||
enum CodeAgentRuntimeMode {
|
||||
builtIn,
|
||||
externalCli,
|
||||
}
|
||||
|
||||
/// Descriptor for additional external Code Agent CLI integrations.
|
||||
class ExternalCodeAgentProvider {
|
||||
const ExternalCodeAgentProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.command,
|
||||
this.defaultArgs = const <String>[],
|
||||
this.capabilities = const <String>[],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String command;
|
||||
final List<String> defaultArgs;
|
||||
final List<String> capabilities;
|
||||
}
|
||||
|
||||
/// Unified runtime coordinator for managing Gateway and Code Agent runtime.
|
||||
///
|
||||
/// This class coordinates:
|
||||
/// - GatewayRuntime: Connection to OpenClaw Gateway
|
||||
/// - CodexRuntime: Local Codex CLI process
|
||||
/// - CodexRuntime: Code agent runtime (external CLI or built-in runtime mode)
|
||||
/// - ModeSwitcher: Local/Remote/Offline mode switching
|
||||
/// - Agent communication and message routing
|
||||
/// - Extensible external code-agent provider descriptors for future CLIs
|
||||
class RuntimeCoordinator extends ChangeNotifier {
|
||||
final GatewayRuntime gateway;
|
||||
final CodexRuntime codex;
|
||||
final CodexConfigBridge configBridge;
|
||||
final ModeSwitcher modeSwitcher;
|
||||
|
||||
final Map<String, ExternalCodeAgentProvider> _externalCodeAgents =
|
||||
<String, ExternalCodeAgentProvider>{};
|
||||
|
||||
CoordinatorState _state = CoordinatorState.disconnected;
|
||||
String? _lastError;
|
||||
String? _codexPath;
|
||||
String? _cwd;
|
||||
CodeAgentRuntimeMode _runtimeMode = CodeAgentRuntimeMode.externalCli;
|
||||
|
||||
CoordinatorState get state => _state;
|
||||
String? get lastError => _lastError;
|
||||
bool get isReady => _state == CoordinatorState.ready;
|
||||
|
||||
|
||||
/// Current code-agent runtime mode.
|
||||
CodeAgentRuntimeMode get runtimeMode => _runtimeMode;
|
||||
|
||||
/// Current gateway mode.
|
||||
GatewayMode get currentMode => modeSwitcher.currentMode;
|
||||
|
||||
|
||||
/// Current capabilities based on mode.
|
||||
ModeCapabilities get capabilities => modeSwitcher.capabilities;
|
||||
|
||||
|
||||
/// Whether cloud memory is available.
|
||||
bool get hasCloudMemory => modeSwitcher.capabilities.hasCloudMemory;
|
||||
|
||||
|
||||
/// Whether task queue is available.
|
||||
bool get hasTaskQueue => modeSwitcher.capabilities.hasTaskQueue;
|
||||
|
||||
/// Registered external code agent providers (future extension point).
|
||||
List<ExternalCodeAgentProvider> get externalCodeAgents =>
|
||||
List<ExternalCodeAgentProvider>.unmodifiable(_externalCodeAgents.values);
|
||||
|
||||
RuntimeCoordinator({
|
||||
required this.gateway,
|
||||
required this.codex,
|
||||
CodexConfigBridge? configBridge,
|
||||
ModeSwitcher? modeSwitcher,
|
||||
}) : configBridge = configBridge ?? CodexConfigBridge(),
|
||||
}) : configBridge = configBridge ?? CodexConfigBridge(),
|
||||
modeSwitcher = modeSwitcher ?? ModeSwitcher(gateway);
|
||||
|
||||
/// Register an external Code Agent CLI provider descriptor.
|
||||
///
|
||||
/// This reserves integration slots for additional CLI-based agents while
|
||||
/// keeping invocation, capability discovery, and scheduling metadata unified.
|
||||
void registerExternalCodeAgent(ExternalCodeAgentProvider provider) {
|
||||
final normalizedId = provider.id.trim();
|
||||
if (normalizedId.isEmpty) {
|
||||
throw ArgumentError.value(provider.id, 'provider.id', 'Cannot be empty');
|
||||
}
|
||||
|
||||
_externalCodeAgents[normalizedId] = ExternalCodeAgentProvider(
|
||||
id: normalizedId,
|
||||
name: provider.name,
|
||||
command: provider.command,
|
||||
defaultArgs: provider.defaultArgs,
|
||||
capabilities: provider.capabilities,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove an external Code Agent CLI provider descriptor.
|
||||
bool unregisterExternalCodeAgent(String providerId) {
|
||||
final removed = _externalCodeAgents.remove(providerId.trim()) != null;
|
||||
if (removed) {
|
||||
notifyListeners();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// Check whether an external provider is known.
|
||||
bool hasExternalCodeAgent(String providerId) {
|
||||
return _externalCodeAgents.containsKey(providerId.trim());
|
||||
}
|
||||
|
||||
/// Initialize the coordinator with Gateway profile and Codex.
|
||||
Future<void> initialize({
|
||||
GatewayConnectionProfile? profile,
|
||||
String? codexPath,
|
||||
String? workingDirectory,
|
||||
GatewayMode preferredMode = GatewayMode.remote,
|
||||
CodeAgentRuntimeMode runtimeMode = CodeAgentRuntimeMode.externalCli,
|
||||
}) async {
|
||||
_state = CoordinatorState.connecting;
|
||||
_runtimeMode = runtimeMode;
|
||||
_codexPath = codexPath;
|
||||
_cwd = workingDirectory ?? Directory.current.path;
|
||||
_lastError = null;
|
||||
@ -75,41 +148,15 @@ class RuntimeCoordinator extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
// Step 1: Connect to Gateway based on preferred mode
|
||||
ModeSwitchResult result;
|
||||
|
||||
switch (preferredMode) {
|
||||
case GatewayMode.local:
|
||||
result = await modeSwitcher.switchToLocal();
|
||||
break;
|
||||
case GatewayMode.remote:
|
||||
result = await modeSwitcher.switchToRemote();
|
||||
break;
|
||||
case GatewayMode.offline:
|
||||
result = await modeSwitcher.switchToOffline();
|
||||
break;
|
||||
}
|
||||
final result = await _switchMode(preferredMode);
|
||||
|
||||
if (!result.success) {
|
||||
throw StateError('Failed to connect: ${result.error}');
|
||||
}
|
||||
|
||||
// Step 2: Find and start Codex (if not in offline mode)
|
||||
// Step 2: Start code-agent runtime according to selected mode.
|
||||
if (preferredMode != GatewayMode.offline) {
|
||||
final resolvedCodexPath = codexPath ?? await codex.findCodexBinary();
|
||||
if (resolvedCodexPath == null) {
|
||||
// Fall back to offline mode if Codex not found
|
||||
await modeSwitcher.switchToOffline();
|
||||
} else {
|
||||
try {
|
||||
await codex.startStdio(
|
||||
codexPath: resolvedCodexPath,
|
||||
cwd: _cwd,
|
||||
);
|
||||
} catch (e) {
|
||||
// Continue without Codex in offline mode
|
||||
await modeSwitcher.switchToOffline();
|
||||
}
|
||||
}
|
||||
await _ensureCodeAgentRuntime();
|
||||
}
|
||||
|
||||
_state = CoordinatorState.ready;
|
||||
@ -127,8 +174,10 @@ class RuntimeCoordinator extends ChangeNotifier {
|
||||
String? codexPath,
|
||||
String? workingDirectory,
|
||||
bool preferRemote = true,
|
||||
CodeAgentRuntimeMode runtimeMode = CodeAgentRuntimeMode.externalCli,
|
||||
}) async {
|
||||
_state = CoordinatorState.connecting;
|
||||
_runtimeMode = runtimeMode;
|
||||
_codexPath = codexPath;
|
||||
_cwd = workingDirectory ?? Directory.current.path;
|
||||
_lastError = null;
|
||||
@ -142,20 +191,8 @@ class RuntimeCoordinator extends ChangeNotifier {
|
||||
throw StateError('No available connection mode: ${result.error}');
|
||||
}
|
||||
|
||||
// Start Codex if available
|
||||
if (result.mode != GatewayMode.offline) {
|
||||
final resolvedCodexPath = codexPath ?? await codex.findCodexBinary();
|
||||
if (resolvedCodexPath != null) {
|
||||
try {
|
||||
await codex.startStdio(
|
||||
codexPath: resolvedCodexPath,
|
||||
cwd: _cwd,
|
||||
);
|
||||
} catch (e) {
|
||||
// Continue in offline mode
|
||||
await modeSwitcher.switchToOffline();
|
||||
}
|
||||
}
|
||||
await _ensureCodeAgentRuntime();
|
||||
}
|
||||
|
||||
_state = CoordinatorState.ready;
|
||||
@ -181,19 +218,7 @@ class RuntimeCoordinator extends ChangeNotifier {
|
||||
|
||||
/// Switch to a different mode.
|
||||
Future<void> switchMode(GatewayMode newMode) async {
|
||||
ModeSwitchResult result;
|
||||
|
||||
switch (newMode) {
|
||||
case GatewayMode.local:
|
||||
result = await modeSwitcher.switchToLocal();
|
||||
break;
|
||||
case GatewayMode.remote:
|
||||
result = await modeSwitcher.switchToRemote();
|
||||
break;
|
||||
case GatewayMode.offline:
|
||||
result = await modeSwitcher.switchToOffline();
|
||||
break;
|
||||
}
|
||||
final result = await _switchMode(newMode);
|
||||
|
||||
if (!result.success) {
|
||||
throw StateError('Failed to switch mode: ${result.error}');
|
||||
@ -223,16 +248,16 @@ class RuntimeCoordinator extends ChangeNotifier {
|
||||
/// Get available modes based on current state.
|
||||
List<GatewayMode> getAvailableModes() {
|
||||
final modes = <GatewayMode>[];
|
||||
|
||||
|
||||
// Always can try local mode
|
||||
modes.add(GatewayMode.local);
|
||||
|
||||
|
||||
// Remote mode requires network
|
||||
modes.add(GatewayMode.remote);
|
||||
|
||||
|
||||
// Offline mode is always available
|
||||
modes.add(GatewayMode.offline);
|
||||
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
@ -258,6 +283,41 @@ class RuntimeCoordinator extends ChangeNotifier {
|
||||
]);
|
||||
}
|
||||
|
||||
Future<ModeSwitchResult> _switchMode(GatewayMode mode) {
|
||||
switch (mode) {
|
||||
case GatewayMode.local:
|
||||
return modeSwitcher.switchToLocal();
|
||||
case GatewayMode.remote:
|
||||
return modeSwitcher.switchToRemote();
|
||||
case GatewayMode.offline:
|
||||
return modeSwitcher.switchToOffline();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureCodeAgentRuntime() async {
|
||||
if (_runtimeMode == CodeAgentRuntimeMode.builtIn) {
|
||||
// Built-in mode: runtime is assumed internal, no external process needed.
|
||||
return;
|
||||
}
|
||||
|
||||
final resolvedCodexPath = _codexPath ?? await codex.findCodexBinary();
|
||||
if (resolvedCodexPath == null) {
|
||||
// Fall back to offline mode if external Codex CLI is unavailable.
|
||||
await modeSwitcher.switchToOffline();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await codex.startStdio(
|
||||
codexPath: resolvedCodexPath,
|
||||
cwd: _cwd,
|
||||
);
|
||||
} catch (_) {
|
||||
// Continue without external code agent in offline mode.
|
||||
await modeSwitcher.switchToOffline();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
shutdown();
|
||||
|
||||
243
test/runtime/runtime_coordinator_test.dart
Normal file
243
test/runtime/runtime_coordinator_test.dart
Normal file
@ -0,0 +1,243 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/codex_runtime.dart';
|
||||
import 'package:xworkmate/runtime/gateway_runtime.dart';
|
||||
import 'package:xworkmate/runtime/mode_switcher.dart';
|
||||
import 'package:xworkmate/runtime/runtime_coordinator.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
class _FakeGatewayRuntime extends ChangeNotifier implements GatewayRuntime {
|
||||
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
|
||||
final StreamController<GatewayPushEvent> _events =
|
||||
StreamController<GatewayPushEvent>.broadcast();
|
||||
|
||||
@override
|
||||
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
|
||||
|
||||
@override
|
||||
GatewayConnectionSnapshot get snapshot => _snapshot;
|
||||
|
||||
@override
|
||||
Stream<GatewayPushEvent> get events => _events.stream;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<void> connectProfile(
|
||||
GatewayConnectionProfile profile, {
|
||||
String authTokenOverride = '',
|
||||
String authPasswordOverride = '',
|
||||
}) async {
|
||||
_snapshot = GatewayConnectionSnapshot(
|
||||
profile: profile,
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_snapshot = GatewayConnectionSnapshot(
|
||||
profile: _snapshot.profile,
|
||||
status: RuntimeConnectionStatus.offline,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> request(
|
||||
String method, {
|
||||
Map<String, dynamic> params = const {},
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
@override
|
||||
void clearLogs() {}
|
||||
|
||||
@override
|
||||
List<RuntimeLogEntry> get logs => const <RuntimeLogEntry>[];
|
||||
|
||||
@override
|
||||
List<RuntimeLogEntry> get logsForTest => const <RuntimeLogEntry>[];
|
||||
|
||||
@override
|
||||
void addRuntimeLogForTest({
|
||||
required String level,
|
||||
required String category,
|
||||
required String message,
|
||||
}) {}
|
||||
}
|
||||
|
||||
class _FakeCodexRuntime extends CodexRuntime {
|
||||
bool findCalled = false;
|
||||
bool startCalled = false;
|
||||
String? findResult;
|
||||
|
||||
@override
|
||||
Future<String?> findCodexBinary() async {
|
||||
findCalled = true;
|
||||
return findResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startStdio({
|
||||
required String codexPath,
|
||||
String? cwd,
|
||||
CodexSandboxMode sandbox = CodexSandboxMode.workspaceWrite,
|
||||
CodexApprovalPolicy approval = CodexApprovalPolicy.suggest,
|
||||
List<String> extraArgs = const <String>[],
|
||||
}) async {
|
||||
startCalled = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
}
|
||||
|
||||
class _FakeModeSwitcher extends ModeSwitcher {
|
||||
_FakeModeSwitcher(super.gateway);
|
||||
|
||||
GatewayMode mode = GatewayMode.offline;
|
||||
ModeCapabilities modeCapabilities = ModeCapabilities.offline;
|
||||
bool offlineSwitchCalled = false;
|
||||
|
||||
@override
|
||||
GatewayMode get currentMode => mode;
|
||||
|
||||
@override
|
||||
ModeCapabilities get capabilities => modeCapabilities;
|
||||
|
||||
@override
|
||||
Future<ModeSwitchResult> switchToLocal({
|
||||
String host = '127.0.0.1',
|
||||
int port = 18789,
|
||||
String? token,
|
||||
}) async {
|
||||
mode = GatewayMode.local;
|
||||
modeCapabilities = ModeCapabilities.local;
|
||||
return ModeSwitchResult(success: true, mode: GatewayMode.local);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ModeSwitchResult> switchToRemote({
|
||||
String host = 'openclaw.svc.plus',
|
||||
int port = 443,
|
||||
bool tls = true,
|
||||
String? token,
|
||||
}) async {
|
||||
mode = GatewayMode.remote;
|
||||
modeCapabilities = ModeCapabilities.remote;
|
||||
return ModeSwitchResult(success: true, mode: GatewayMode.remote);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ModeSwitchResult> switchToOffline() async {
|
||||
offlineSwitchCalled = true;
|
||||
mode = GatewayMode.offline;
|
||||
modeCapabilities = ModeCapabilities.offline;
|
||||
return ModeSwitchResult(success: true, mode: GatewayMode.offline);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ModeSwitchResult> autoSelect({bool preferRemote = true}) async {
|
||||
return preferRemote ? switchToRemote() : switchToLocal();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('RuntimeCoordinator runtime modes', () {
|
||||
late _FakeGatewayRuntime gateway;
|
||||
late _FakeCodexRuntime codex;
|
||||
late _FakeModeSwitcher modeSwitcher;
|
||||
late RuntimeCoordinator coordinator;
|
||||
|
||||
setUp(() {
|
||||
gateway = _FakeGatewayRuntime();
|
||||
codex = _FakeCodexRuntime();
|
||||
modeSwitcher = _FakeModeSwitcher(gateway);
|
||||
coordinator = RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: codex,
|
||||
modeSwitcher: modeSwitcher,
|
||||
);
|
||||
});
|
||||
|
||||
test('built-in mode does not resolve or start external codex process', () async {
|
||||
codex.findResult = '/usr/local/bin/codex';
|
||||
|
||||
await coordinator.initialize(
|
||||
preferredMode: GatewayMode.remote,
|
||||
runtimeMode: CodeAgentRuntimeMode.builtIn,
|
||||
);
|
||||
|
||||
expect(coordinator.runtimeMode, CodeAgentRuntimeMode.builtIn);
|
||||
expect(codex.findCalled, isFalse);
|
||||
expect(codex.startCalled, isFalse);
|
||||
expect(coordinator.isReady, isTrue);
|
||||
});
|
||||
|
||||
test('external mode resolves and starts codex process when binary exists', () async {
|
||||
codex.findResult = '/usr/local/bin/codex';
|
||||
|
||||
await coordinator.initialize(
|
||||
preferredMode: GatewayMode.remote,
|
||||
runtimeMode: CodeAgentRuntimeMode.externalCli,
|
||||
);
|
||||
|
||||
expect(coordinator.runtimeMode, CodeAgentRuntimeMode.externalCli);
|
||||
expect(codex.findCalled, isTrue);
|
||||
expect(codex.startCalled, isTrue);
|
||||
expect(modeSwitcher.currentMode, GatewayMode.remote);
|
||||
});
|
||||
|
||||
test('external mode falls back to offline when codex binary missing', () async {
|
||||
codex.findResult = null;
|
||||
|
||||
await coordinator.initialize(
|
||||
preferredMode: GatewayMode.remote,
|
||||
runtimeMode: CodeAgentRuntimeMode.externalCli,
|
||||
);
|
||||
|
||||
expect(codex.findCalled, isTrue);
|
||||
expect(codex.startCalled, isFalse);
|
||||
expect(modeSwitcher.offlineSwitchCalled, isTrue);
|
||||
expect(modeSwitcher.currentMode, GatewayMode.offline);
|
||||
});
|
||||
});
|
||||
|
||||
group('RuntimeCoordinator external provider registry', () {
|
||||
late RuntimeCoordinator coordinator;
|
||||
|
||||
setUp(() {
|
||||
final gateway = _FakeGatewayRuntime();
|
||||
final codex = _FakeCodexRuntime();
|
||||
coordinator = RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: codex,
|
||||
modeSwitcher: _FakeModeSwitcher(gateway),
|
||||
);
|
||||
});
|
||||
|
||||
test('registers and unregisters external code agent providers', () {
|
||||
const provider = ExternalCodeAgentProvider(
|
||||
id: 'qwen-cli',
|
||||
name: 'Qwen CLI',
|
||||
command: 'qwen',
|
||||
defaultArgs: <String>['serve'],
|
||||
capabilities: <String>['chat', 'code-edit'],
|
||||
);
|
||||
|
||||
coordinator.registerExternalCodeAgent(provider);
|
||||
|
||||
expect(coordinator.hasExternalCodeAgent('qwen-cli'), isTrue);
|
||||
expect(coordinator.externalCodeAgents, hasLength(1));
|
||||
|
||||
final removed = coordinator.unregisterExternalCodeAgent('qwen-cli');
|
||||
expect(removed, isTrue);
|
||||
expect(coordinator.externalCodeAgents, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user