diff --git a/docs/codex-integration/tasks.md b/docs/codex-integration/tasks.md index 008d7e41..ef205b5d 100644 --- a/docs/codex-integration/tasks.md +++ b/docs/codex-integration/tasks.md @@ -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 = │ +│ .env: AI-Gateway-apiKey = │ │ │ │ 模式切换: │ │ - Local: 127.0.0.1:18789 (本地代理) │ diff --git a/lib/runtime/runtime_coordinator.dart b/lib/runtime/runtime_coordinator.dart index 34fa7cb6..fcd6e363 100644 --- a/lib/runtime/runtime_coordinator.dart +++ b/lib/runtime/runtime_coordinator.dart @@ -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 [], + this.capabilities = const [], + }); + + final String id; + final String name; + final String command; + final List defaultArgs; + final List 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 _externalCodeAgents = + {}; + 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 get externalCodeAgents => + List.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 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 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 getAvailableModes() { final modes = []; - + // 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 _switchMode(GatewayMode mode) { + switch (mode) { + case GatewayMode.local: + return modeSwitcher.switchToLocal(); + case GatewayMode.remote: + return modeSwitcher.switchToRemote(); + case GatewayMode.offline: + return modeSwitcher.switchToOffline(); + } + } + + Future _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(); diff --git a/test/runtime/runtime_coordinator_test.dart b/test/runtime/runtime_coordinator_test.dart new file mode 100644 index 00000000..141f76f3 --- /dev/null +++ b/test/runtime/runtime_coordinator_test.dart @@ -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 _events = + StreamController.broadcast(); + + @override + bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; + + @override + GatewayConnectionSnapshot get snapshot => _snapshot; + + @override + Stream get events => _events.stream; + + @override + Future initialize() async {} + + @override + Future connectProfile( + GatewayConnectionProfile profile, { + String authTokenOverride = '', + String authPasswordOverride = '', + }) async { + _snapshot = GatewayConnectionSnapshot( + profile: profile, + status: RuntimeConnectionStatus.connected, + ); + } + + @override + Future disconnect() async { + _snapshot = GatewayConnectionSnapshot( + profile: _snapshot.profile, + status: RuntimeConnectionStatus.offline, + ); + } + + @override + Future> request( + String method, { + Map params = const {}, + Duration timeout = const Duration(seconds: 30), + }) async { + return {}; + } + + @override + void clearLogs() {} + + @override + List get logs => const []; + + @override + List get logsForTest => const []; + + @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 findCodexBinary() async { + findCalled = true; + return findResult; + } + + @override + Future startStdio({ + required String codexPath, + String? cwd, + CodexSandboxMode sandbox = CodexSandboxMode.workspaceWrite, + CodexApprovalPolicy approval = CodexApprovalPolicy.suggest, + List extraArgs = const [], + }) async { + startCalled = true; + } + + @override + Future 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 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 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 switchToOffline() async { + offlineSwitchCalled = true; + mode = GatewayMode.offline; + modeCapabilities = ModeCapabilities.offline; + return ModeSwitchResult(success: true, mode: GatewayMode.offline); + } + + @override + Future 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: ['serve'], + capabilities: ['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); + }); + }); +}