From 73c5b6110c14f8c9d83fff3d14e31e540931a08a Mon Sep 17 00:00:00 2001 From: Norbert515 Date: Sat, 14 Feb 2026 21:09:03 +0100 Subject: [PATCH 1/3] feat: add standalone codex_sdk package for Codex CLI integration Adds codex_sdk as a standalone package that wraps OpenAI's Codex CLI (codex exec --json) and maps its JSONL events to ClaudeResponse types, allowing the existing downstream pipeline to work unchanged. Key components: - CodexClient: process lifecycle, multi-turn resume via stdin - CodexEventParser/Mapper: JSONL parsing and response mapping - CodexConfig: CLI flag generation with correct ordering - CodexMcpRegistry: .codex/config.toml for MCP server discovery --- packages/codex_sdk/lib/codex_sdk.dart | 8 + .../lib/src/client/codex_client.dart | 435 ++++++++++++++++++ .../lib/src/config/codex_config.dart | 101 ++++ .../lib/src/config/codex_mcp_registry.dart | 60 +++ .../lib/src/protocol/codex_event.dart | 153 ++++++ .../lib/src/protocol/codex_event_mapper.dart | 329 +++++++++++++ .../lib/src/protocol/codex_event_parser.dart | 32 ++ packages/codex_sdk/pubspec.yaml | 16 + pubspec.yaml | 1 + 9 files changed, 1135 insertions(+) create mode 100644 packages/codex_sdk/lib/codex_sdk.dart create mode 100644 packages/codex_sdk/lib/src/client/codex_client.dart create mode 100644 packages/codex_sdk/lib/src/config/codex_config.dart create mode 100644 packages/codex_sdk/lib/src/config/codex_mcp_registry.dart create mode 100644 packages/codex_sdk/lib/src/protocol/codex_event.dart create mode 100644 packages/codex_sdk/lib/src/protocol/codex_event_mapper.dart create mode 100644 packages/codex_sdk/lib/src/protocol/codex_event_parser.dart create mode 100644 packages/codex_sdk/pubspec.yaml diff --git a/packages/codex_sdk/lib/codex_sdk.dart b/packages/codex_sdk/lib/codex_sdk.dart new file mode 100644 index 0000000..320bc6e --- /dev/null +++ b/packages/codex_sdk/lib/codex_sdk.dart @@ -0,0 +1,8 @@ +library codex_sdk; + +export 'src/client/codex_client.dart'; +export 'src/config/codex_config.dart'; +export 'src/config/codex_mcp_registry.dart'; +export 'src/protocol/codex_event.dart'; +export 'src/protocol/codex_event_parser.dart'; +export 'src/protocol/codex_event_mapper.dart'; diff --git a/packages/codex_sdk/lib/src/client/codex_client.dart b/packages/codex_sdk/lib/src/client/codex_client.dart new file mode 100644 index 0000000..2855521 --- /dev/null +++ b/packages/codex_sdk/lib/src/client/codex_client.dart @@ -0,0 +1,435 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:claude_sdk/claude_sdk.dart'; +import 'package:uuid/uuid.dart'; + +import '../config/codex_config.dart'; +import '../config/codex_mcp_registry.dart'; +import '../protocol/codex_event.dart'; +import '../protocol/codex_event_mapper.dart'; +import '../protocol/codex_event_parser.dart'; + +/// Standalone client backed by Codex CLI (`codex exec --json`). +/// +/// Each turn spawns a new `codex exec` process (or `codex exec resume ` +/// for multi-turn). The JSONL events are parsed, mapped to [ClaudeResponse] +/// objects, and fed through [ResponseProcessor] so the entire downstream +/// pipeline (Conversation, TUI, vide_server) works unchanged. +class CodexClient { + final CodexConfig codexConfig; + final List mcpServers; + final ResponseProcessor _responseProcessor = ResponseProcessor(); + final CodexEventParser _parser = CodexEventParser(); + final CodexEventMapper _mapper = CodexEventMapper(); + + String? _threadId; + Process? _activeProcess; + bool _isInitialized = false; + bool _turnFinished = false; + final Completer _initializedCompleter = Completer(); + + final String _sessionId; + final String _workingDirectory; + + // Stream controllers + final _conversationController = StreamController.broadcast(); + final _turnCompleteController = StreamController.broadcast(); + final _statusController = StreamController.broadcast(); + final _initDataController = StreamController.broadcast(); + final _queuedMessageController = StreamController.broadcast(); + + Conversation _currentConversation = Conversation.empty(); + ClaudeStatus _currentStatus = ClaudeStatus.ready; + MetaResponse? _initData; + + String? _queuedMessageText; + List? _queuedAttachments; + + void Function(MetaResponse response)? onMetaResponseReceived; + + CodexClient({required this.codexConfig, List? mcpServers}) + : mcpServers = mcpServers ?? [], + _sessionId = codexConfig.sessionId ?? const Uuid().v4(), + _workingDirectory = + codexConfig.workingDirectory ?? Directory.current.path { + // Auto-flush queued messages when turn completes + _turnCompleteController.stream.listen((_) { + _flushQueuedMessage(); + }); + } + + /// Initialize the client: start MCP servers and mark as ready. + Future init() async { + if (_isInitialized) return; + + // Start MCP servers + for (final server in mcpServers) { + if (!server.isRunning) { + await server.start(); + } + } + + _isInitialized = true; + if (!_initializedCompleter.isCompleted) { + _initializedCompleter.complete(); + } + } + + // ============================================================ + // Public API + // ============================================================ + + String get sessionId => _sessionId; + + String get workingDirectory => _workingDirectory; + + Stream get conversation => _conversationController.stream; + + Conversation get currentConversation => _currentConversation; + + Future get initialized => _initializedCompleter.future; + + Stream get initDataStream => _initDataController.stream; + + MetaResponse? get initData => _initData; + + Stream get onTurnComplete => _turnCompleteController.stream; + + Stream get statusStream => _statusController.stream; + + ClaudeStatus get currentStatus => _currentStatus; + + Stream get queuedMessage => _queuedMessageController.stream; + + String? get currentQueuedMessage => _queuedMessageText; + + void clearQueuedMessage() { + _queuedMessageText = null; + _queuedAttachments = null; + _queuedMessageController.add(null); + } + + void sendMessage(Message message) { + if (message.text.trim().isEmpty && (message.attachments?.isEmpty ?? true)) { + return; + } + + // If currently processing, queue the message + if (_currentConversation.isProcessing) { + _queueMessage(message); + return; + } + + // Add user message to conversation optimistically + final userMessage = ConversationMessage.user( + content: message.text, + attachments: message.attachments, + ); + _updateConversation( + _currentConversation + .addMessage(userMessage) + .withState(ConversationState.sendingMessage), + ); + + _updateStatus(ClaudeStatus.processing); + + // Spawn codex exec process + _runCodexExec(message.text); + } + + void injectToolResult(ToolResultResponse toolResult) { + // Codex manages its own tool execution; this is a no-op. + // We still update the conversation for UI consistency. + if (_currentConversation.messages.isEmpty) return; + + final lastIndex = _currentConversation.messages.length - 1; + final lastMessage = _currentConversation.messages[lastIndex]; + if (lastMessage.role != MessageRole.assistant) return; + + final updatedMessage = lastMessage.copyWith( + responses: [...lastMessage.responses, toolResult], + ); + final updatedMessages = [..._currentConversation.messages]; + updatedMessages[lastIndex] = updatedMessage; + _updateConversation( + _currentConversation.copyWith(messages: updatedMessages), + ); + } + + Future abort() async { + _activeProcess?.kill(ProcessSignal.sigint); + _activeProcess = null; + _updateStatus(ClaudeStatus.ready); + } + + Future close() async { + _activeProcess?.kill(ProcessSignal.sigterm); + _activeProcess = null; + + for (final server in mcpServers) { + await server.stop(); + } + + await CodexMcpRegistry.cleanUp(workingDirectory: _workingDirectory); + + await _conversationController.close(); + await _turnCompleteController.close(); + await _statusController.close(); + await _initDataController.close(); + await _queuedMessageController.close(); + + _isInitialized = false; + } + + Future clearConversation() async { + _updateConversation(Conversation.empty()); + _threadId = null; + } + + T? getMcpServer(String name) { + try { + return mcpServers.whereType().firstWhere((s) => s.name == name); + } catch (_) { + return null; + } + } + + // ============================================================ + // Private implementation + // ============================================================ + + Future _runCodexExec(String prompt) async { + _turnFinished = false; + final isResume = _threadId != null; + + final args = codexConfig.toCliArgs( + isResume: isResume, + resumeThreadId: _threadId, + ); + + // Write MCP config if we have servers + if (mcpServers.isNotEmpty) { + await CodexMcpRegistry.writeConfig( + mcpServers: mcpServers, + workingDirectory: _workingDirectory, + ); + } + + // For non-resume, add the prompt as a positional argument + if (!isResume) { + args.add(prompt); + } + + try { + final process = await Process.start( + 'codex', + args, + workingDirectory: _workingDirectory, + environment: { + ...Platform.environment, + // Ensure JSON output even if config doesn't set it + 'CODEX_OUTPUT_FORMAT': 'json', + }, + ); + + _activeProcess = process; + + // For resume, send the prompt via stdin + if (isResume) { + process.stdin.writeln(prompt); + await process.stdin.flush(); + } + + // Process stdout (JSONL events) + final stdoutBuffer = StringBuffer(); + process.stdout + .transform(utf8.decoder) + .listen( + (chunk) { + stdoutBuffer.write(chunk); + _processChunk(stdoutBuffer); + }, + onDone: () { + // Process any remaining data in buffer + _processRemainingBuffer(stdoutBuffer); + _onProcessDone(); + }, + onError: (error) { + _handleProcessError('stdout error: $error'); + }, + ); + + // Capture stderr for error reporting + final stderrBuffer = StringBuffer(); + process.stderr.transform(utf8.decoder).listen((chunk) { + stderrBuffer.write(chunk); + }); + + // Handle process exit + process.exitCode.then((exitCode) { + _activeProcess = null; + if (_turnFinished) return; + if (exitCode != 0 && stderrBuffer.isNotEmpty) { + _handleProcessError( + 'Codex exited with code $exitCode: ${stderrBuffer.toString()}', + ); + } + }); + } catch (e) { + _handleProcessError('Failed to start codex: $e'); + } + } + + void _processChunk(StringBuffer buffer) { + // Extract complete lines from the buffer + final content = buffer.toString(); + final lines = content.split('\n'); + + // Keep the last incomplete line in the buffer + buffer.clear(); + if (!content.endsWith('\n') && lines.isNotEmpty) { + buffer.write(lines.removeLast()); + } else if (lines.isNotEmpty && lines.last.isEmpty) { + lines.removeLast(); + } + + for (final line in lines) { + if (line.trim().isEmpty) continue; + + final event = _parser.parseLine(line); + if (event == null) continue; + + _handleEvent(event); + } + } + + void _processRemainingBuffer(StringBuffer buffer) { + final remaining = buffer.toString().trim(); + if (remaining.isEmpty) return; + + final event = _parser.parseLine(remaining); + if (event != null) { + _handleEvent(event); + } + buffer.clear(); + } + + void _handleEvent(CodexEvent event) { + // Capture thread ID from thread.started + if (event is ThreadStartedEvent) { + _threadId = event.threadId; + } + + // Map to ClaudeResponse objects + final responses = _mapper.mapEvent(event); + + var conversation = _currentConversation; + var turnComplete = false; + + for (final response in responses) { + // Handle status updates + if (response is StatusResponse) { + _updateStatus(response.status); + } + + // Handle init data (MetaResponse) + if (response is MetaResponse) { + _initData = response; + _initDataController.add(response); + onMetaResponseReceived?.call(response); + } + + // Process through ResponseProcessor + final result = _responseProcessor.processResponse(response, conversation); + conversation = result.updatedConversation; + turnComplete = turnComplete || result.turnComplete; + } + + _updateConversation(conversation); + + if (turnComplete) { + _updateStatus(ClaudeStatus.ready); + _turnCompleteController.add(null); + } + } + + void _onProcessDone() { + if (_turnFinished) return; + _turnFinished = true; + // If we never got a completion event, emit one now + if (_currentConversation.isProcessing) { + final completionResponse = CompletionResponse( + id: 'codex_done_${DateTime.now().millisecondsSinceEpoch}', + timestamp: DateTime.now(), + stopReason: 'completed', + ); + + final result = _responseProcessor.processResponse( + completionResponse, + _currentConversation, + ); + _updateConversation(result.updatedConversation); + _updateStatus(ClaudeStatus.ready); + _turnCompleteController.add(null); + } + } + + void _handleProcessError(String error) { + final errorResponse = ErrorResponse( + id: 'codex_error_${DateTime.now().millisecondsSinceEpoch}', + timestamp: DateTime.now(), + error: error, + ); + + final result = _responseProcessor.processResponse( + errorResponse, + _currentConversation, + ); + _updateConversation(result.updatedConversation); + _updateStatus(ClaudeStatus.ready); + _turnCompleteController.add(null); + } + + void _updateConversation(Conversation newConversation) { + _currentConversation = newConversation; + _conversationController.add(_currentConversation); + } + + void _updateStatus(ClaudeStatus status) { + if (_currentStatus != status) { + _currentStatus = status; + _statusController.add(status); + } + } + + void _queueMessage(Message message) { + if (_queuedMessageText == null) { + _queuedMessageText = message.text; + _queuedAttachments = message.attachments; + } else { + _queuedMessageText = '$_queuedMessageText\n${message.text}'; + if (message.attachments != null) { + _queuedAttachments = [ + ...(_queuedAttachments ?? []), + ...message.attachments!, + ]; + } + } + _queuedMessageController.add(_queuedMessageText); + } + + void _flushQueuedMessage() { + if (_queuedMessageText == null) return; + + final text = _queuedMessageText!; + final attachments = _queuedAttachments; + + _queuedMessageText = null; + _queuedAttachments = null; + _queuedMessageController.add(null); + + sendMessage(Message(text: text, attachments: attachments)); + } +} diff --git a/packages/codex_sdk/lib/src/config/codex_config.dart b/packages/codex_sdk/lib/src/config/codex_config.dart new file mode 100644 index 0000000..8603bb5 --- /dev/null +++ b/packages/codex_sdk/lib/src/config/codex_config.dart @@ -0,0 +1,101 @@ +class CodexConfig { + final String? model; + final String? profile; + final String approvalPolicy; + final String sandboxMode; + final String? workingDirectory; + final String? sessionId; + final String? appendSystemPrompt; + final List? additionalFlags; + final bool fullAuto; + final List? additionalDirs; + + const CodexConfig({ + this.model, + this.profile, + this.approvalPolicy = 'on-request', + this.sandboxMode = 'workspace-write', + this.workingDirectory, + this.sessionId, + this.appendSystemPrompt, + this.additionalFlags, + this.fullAuto = true, + this.additionalDirs, + }); + + List toCliArgs({bool isResume = false, String? resumeThreadId}) { + // Codex CLI expects: codex exec [FLAGS] [resume ] [prompt] + // All flags must come before the resume subcommand. + final args = ['exec']; + + args.add('--json'); + + if (fullAuto) { + args.add('--full-auto'); + } + + if (model != null) { + args.addAll(['--model', model!]); + } + + if (profile != null) { + args.addAll(['--profile', profile!]); + } + + if (!fullAuto) { + args.addAll(['--ask-for-approval', approvalPolicy]); + } + + if (sandboxMode != 'workspace-write') { + args.addAll(['--sandbox', sandboxMode]); + } + + if (appendSystemPrompt != null) { + args.addAll(['-c', 'instructions.append=$appendSystemPrompt']); + } + + if (additionalDirs != null) { + for (final dir in additionalDirs!) { + args.addAll(['--add-dir', dir]); + } + } + + if (additionalFlags != null) { + args.addAll(additionalFlags!); + } + + // resume subcommand goes after all flags + if (isResume && resumeThreadId != null) { + args.add('resume'); + args.add(resumeThreadId); + } + + return args; + } + + CodexConfig copyWith({ + String? model, + String? profile, + String? approvalPolicy, + String? sandboxMode, + String? workingDirectory, + String? sessionId, + String? appendSystemPrompt, + List? additionalFlags, + bool? fullAuto, + List? additionalDirs, + }) { + return CodexConfig( + model: model ?? this.model, + profile: profile ?? this.profile, + approvalPolicy: approvalPolicy ?? this.approvalPolicy, + sandboxMode: sandboxMode ?? this.sandboxMode, + workingDirectory: workingDirectory ?? this.workingDirectory, + sessionId: sessionId ?? this.sessionId, + appendSystemPrompt: appendSystemPrompt ?? this.appendSystemPrompt, + additionalFlags: additionalFlags ?? this.additionalFlags, + fullAuto: fullAuto ?? this.fullAuto, + additionalDirs: additionalDirs ?? this.additionalDirs, + ); + } +} diff --git a/packages/codex_sdk/lib/src/config/codex_mcp_registry.dart b/packages/codex_sdk/lib/src/config/codex_mcp_registry.dart new file mode 100644 index 0000000..de8fa99 --- /dev/null +++ b/packages/codex_sdk/lib/src/config/codex_mcp_registry.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:claude_sdk/claude_sdk.dart'; +import 'package:path/path.dart' as p; + +/// Writes a temporary `.codex/config.toml` in the working directory +/// so that `codex exec` discovers our MCP servers. +/// +/// Codex CLI reads MCP server configuration from its config file. +/// Each server is registered as a `[mcp_servers.]` section +/// with the `url` pointing to our running [McpServerBase] instances. +class CodexMcpRegistry { + /// Write `.codex/config.toml` with MCP server entries. + /// + /// If the file already exists, it is overwritten. The file is written + /// to `/.codex/config.toml`. + static Future writeConfig({ + required List mcpServers, + required String workingDirectory, + }) async { + if (mcpServers.isEmpty) return; + + final configDir = Directory(p.join(workingDirectory, '.codex')); + if (!configDir.existsSync()) { + configDir.createSync(recursive: true); + } + + final buffer = StringBuffer(); + buffer.writeln('# Auto-generated by codex_sdk — do not edit manually'); + buffer.writeln(); + + for (final server in mcpServers) { + if (!server.isRunning) continue; + + final config = server.toClaudeConfig(); + final url = config['url'] as String; + + buffer.writeln('[mcp_servers.${_sanitizeName(server.name)}]'); + buffer.writeln('url = "$url"'); + buffer.writeln(); + } + + final configFile = File(p.join(configDir.path, 'config.toml')); + await configFile.writeAsString(buffer.toString()); + } + + /// Remove the generated config file. + static Future cleanUp({required String workingDirectory}) async { + final configFile = File(p.join(workingDirectory, '.codex', 'config.toml')); + if (configFile.existsSync()) { + await configFile.delete(); + } + } + + /// Sanitize server name for use as a TOML key. + /// Replaces non-alphanumeric characters with underscores. + static String _sanitizeName(String name) { + return name.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_'); + } +} diff --git a/packages/codex_sdk/lib/src/protocol/codex_event.dart b/packages/codex_sdk/lib/src/protocol/codex_event.dart new file mode 100644 index 0000000..5b92a17 --- /dev/null +++ b/packages/codex_sdk/lib/src/protocol/codex_event.dart @@ -0,0 +1,153 @@ +/// Codex CLI JSONL event types. +/// +/// These represent the structured events emitted by `codex exec --json`. +sealed class CodexEvent { + const CodexEvent(); + + factory CodexEvent.fromJson(Map json) { + final type = json['type'] as String? ?? ''; + return switch (type) { + 'thread.started' => ThreadStartedEvent.fromJson(json), + 'turn.started' => const TurnStartedEvent(), + 'turn.completed' => TurnCompletedEvent.fromJson(json), + 'turn.failed' => TurnFailedEvent.fromJson(json), + 'item.started' || + 'item.updated' || + 'item.completed' => ItemEvent.fromJson(type, json), + 'error' => CodexErrorEvent.fromJson(json), + _ => UnknownCodexEvent(json), + }; + } +} + +class ThreadStartedEvent extends CodexEvent { + final String threadId; + + const ThreadStartedEvent({required this.threadId}); + + factory ThreadStartedEvent.fromJson(Map json) { + return ThreadStartedEvent(threadId: json['thread_id'] as String? ?? ''); + } +} + +class TurnStartedEvent extends CodexEvent { + const TurnStartedEvent(); +} + +class TurnCompletedEvent extends CodexEvent { + final CodexUsage? usage; + + const TurnCompletedEvent({this.usage}); + + factory TurnCompletedEvent.fromJson(Map json) { + final usageJson = json['usage'] as Map?; + return TurnCompletedEvent( + usage: usageJson != null ? CodexUsage.fromJson(usageJson) : null, + ); + } +} + +class TurnFailedEvent extends CodexEvent { + final String? error; + final Map? details; + + const TurnFailedEvent({this.error, this.details}); + + factory TurnFailedEvent.fromJson(Map json) { + final errorData = json['error']; + String? errorMessage; + Map? details; + + if (errorData is String) { + errorMessage = errorData; + } else if (errorData is Map) { + errorMessage = errorData['message'] as String?; + details = errorData; + } + + return TurnFailedEvent(error: errorMessage, details: details); + } +} + +class ItemEvent extends CodexEvent { + /// One of 'item.started', 'item.updated', 'item.completed' + final String eventType; + final String itemId; + + /// One of 'agent_message', 'command_execution', 'file_change', + /// 'mcp_tool_call', 'reasoning', 'web_search', 'plan_update' + final String itemType; + final String? status; + final Map data; + + const ItemEvent({ + required this.eventType, + required this.itemId, + required this.itemType, + this.status, + required this.data, + }); + + factory ItemEvent.fromJson(String eventType, Map json) { + final item = json['item'] as Map? ?? {}; + return ItemEvent( + eventType: eventType, + itemId: item['id'] as String? ?? '', + itemType: item['type'] as String? ?? '', + status: item['status'] as String?, + data: item, + ); + } + + bool get isStarted => eventType == 'item.started'; + bool get isUpdated => eventType == 'item.updated'; + bool get isCompleted => eventType == 'item.completed'; +} + +class CodexErrorEvent extends CodexEvent { + final String message; + final Map? details; + + const CodexErrorEvent({required this.message, this.details}); + + factory CodexErrorEvent.fromJson(Map json) { + final error = json['error']; + if (error is String) { + return CodexErrorEvent(message: error); + } else if (error is Map) { + return CodexErrorEvent( + message: error['message'] as String? ?? 'Unknown error', + details: error, + ); + } + return CodexErrorEvent( + message: json['message'] as String? ?? 'Unknown error', + details: json, + ); + } +} + +class UnknownCodexEvent extends CodexEvent { + final Map rawData; + const UnknownCodexEvent(this.rawData); +} + +class CodexUsage { + final int inputTokens; + final int cachedInputTokens; + final int outputTokens; + + const CodexUsage({ + required this.inputTokens, + required this.cachedInputTokens, + required this.outputTokens, + }); + + factory CodexUsage.fromJson(Map json) { + return CodexUsage( + inputTokens: json['input_tokens'] as int? ?? 0, + cachedInputTokens: json['cached_input_tokens'] as int? ?? 0, + outputTokens: json['output_tokens'] as int? ?? 0, + ); + } +} diff --git a/packages/codex_sdk/lib/src/protocol/codex_event_mapper.dart b/packages/codex_sdk/lib/src/protocol/codex_event_mapper.dart new file mode 100644 index 0000000..3a04610 --- /dev/null +++ b/packages/codex_sdk/lib/src/protocol/codex_event_mapper.dart @@ -0,0 +1,329 @@ +import 'package:claude_sdk/claude_sdk.dart'; + +import 'codex_event.dart'; + +/// Maps [CodexEvent]s to [ClaudeResponse] objects. +/// +/// This allows the entire downstream pipeline (ResponseProcessor, +/// Conversation, TUI, vide_server) to work unchanged — they all +/// consume ClaudeResponse types regardless of which backend produced them. +class CodexEventMapper { + int _idCounter = 0; + + String _nextId() => 'codex_${_idCounter++}'; + + /// Map a single Codex event to zero or more ClaudeResponse objects. + List mapEvent(CodexEvent event) { + return switch (event) { + ThreadStartedEvent e => _mapThreadStarted(e), + TurnStartedEvent _ => _mapTurnStarted(), + TurnCompletedEvent e => _mapTurnCompleted(e), + TurnFailedEvent e => _mapTurnFailed(e), + ItemEvent e => _mapItem(e), + CodexErrorEvent e => _mapError(e), + UnknownCodexEvent _ => [], + }; + } + + List _mapThreadStarted(ThreadStartedEvent event) { + return [ + MetaResponse( + id: event.threadId, + timestamp: DateTime.now(), + metadata: {'session_id': event.threadId}, + ), + ]; + } + + List _mapTurnStarted() { + return [ + StatusResponse( + id: _nextId(), + timestamp: DateTime.now(), + status: ClaudeStatus.processing, + ), + ]; + } + + List _mapTurnCompleted(TurnCompletedEvent event) { + return [ + CompletionResponse( + id: _nextId(), + timestamp: DateTime.now(), + stopReason: 'completed', + inputTokens: event.usage?.inputTokens, + outputTokens: event.usage?.outputTokens, + cacheReadInputTokens: event.usage?.cachedInputTokens, + ), + ]; + } + + List _mapTurnFailed(TurnFailedEvent event) { + return [ + ErrorResponse( + id: _nextId(), + timestamp: DateTime.now(), + error: event.error ?? 'Turn failed', + details: event.details?.toString(), + ), + ]; + } + + List _mapError(CodexErrorEvent event) { + return [ + ErrorResponse( + id: _nextId(), + timestamp: DateTime.now(), + error: event.message, + details: event.details?.toString(), + ), + ]; + } + + List _mapItem(ItemEvent event) { + return switch (event.itemType) { + 'agent_message' => _mapAgentMessage(event), + 'command_execution' => _mapCommandExecution(event), + 'file_change' => _mapFileChange(event), + 'mcp_tool_call' => _mapMcpToolCall(event), + 'reasoning' => _mapReasoning(event), + 'web_search' => _mapWebSearch(event), + 'todo_list' => _mapTodoList(event), + _ => [], + }; + } + + List _mapAgentMessage(ItemEvent event) { + if (!event.isCompleted) return []; + final text = event.data['text'] as String? ?? ''; + if (text.isEmpty) return []; + return [ + TextResponse( + id: event.itemId, + timestamp: DateTime.now(), + content: text, + isCumulative: true, + ), + ]; + } + + List _mapCommandExecution(ItemEvent event) { + if (event.isStarted) { + return [ + ToolUseResponse( + id: event.itemId, + timestamp: DateTime.now(), + toolName: 'Bash', + parameters: {'command': event.data['command'] as String? ?? ''}, + toolUseId: event.itemId, + ), + ]; + } + if (event.isCompleted) { + final exitCode = event.data['exit_code'] as int?; + final output = event.data['aggregated_output'] as String? ?? ''; + return [ + ToolResultResponse( + id: '${event.itemId}_result', + timestamp: DateTime.now(), + toolUseId: event.itemId, + content: output, + isError: exitCode != null && exitCode != 0, + ), + ]; + } + return []; + } + + List _mapFileChange(ItemEvent event) { + // Codex emits file changes with a `changes` array: + // { "changes": [{ "path": "lib/foo.dart", "kind": "add" }, ...] } + // where kind is "add", "update", or "delete". + final changes = event.data['changes'] as List?; + + if (event.isStarted) { + final params = {}; + if (changes != null && changes.isNotEmpty) { + final paths = changes + .map((c) => (c as Map)['path'] as String? ?? '') + .toList(); + params['files'] = paths; + final kind = (changes.first as Map)['kind'] as String? ?? 'update'; + params['kind'] = kind; + } + final toolName = _inferFileToolName(changes); + return [ + ToolUseResponse( + id: event.itemId, + timestamp: DateTime.now(), + toolName: toolName, + parameters: params, + toolUseId: event.itemId, + ), + ]; + } + if (event.isCompleted) { + final summary = changes != null + ? changes + .map((c) { + final path = (c as Map)['path'] ?? ''; + final kind = c['kind'] ?? ''; + return '$kind: $path'; + }) + .join('\n') + : 'Done'; + return [ + ToolResultResponse( + id: '${event.itemId}_result', + timestamp: DateTime.now(), + toolUseId: event.itemId, + content: summary, + isError: false, + ), + ]; + } + return []; + } + + List _mapMcpToolCall(ItemEvent event) { + if (event.isStarted) { + final serverLabel = event.data['server'] as String? ?? ''; + final toolName = event.data['tool'] as String? ?? ''; + final fullName = serverLabel.isNotEmpty + ? 'mcp__${serverLabel}__$toolName' + : toolName; + final arguments = event.data['arguments']; + return [ + ToolUseResponse( + id: event.itemId, + timestamp: DateTime.now(), + toolName: fullName, + parameters: arguments is Map + ? arguments + : {}, + toolUseId: event.itemId, + ), + ]; + } + if (event.isCompleted) { + final error = event.data['error']; + final isError = error != null; + final content = isError + ? _extractErrorMessage(error) + : _extractMcpResult(event.data['result']); + return [ + ToolResultResponse( + id: '${event.itemId}_result', + timestamp: DateTime.now(), + toolUseId: event.itemId, + content: content, + isError: isError, + ), + ]; + } + return []; + } + + List _mapReasoning(ItemEvent event) { + if (!event.isCompleted) return []; + final text = + event.data['text'] as String? ?? event.data['summary'] as String? ?? ''; + if (text.isEmpty) return []; + return [ + TextResponse( + id: event.itemId, + timestamp: DateTime.now(), + content: text, + isCumulative: true, + ), + ]; + } + + List _mapWebSearch(ItemEvent event) { + if (event.isStarted) { + final query = event.data['query'] as String? ?? ''; + return [ + ToolUseResponse( + id: event.itemId, + timestamp: DateTime.now(), + toolName: 'WebSearch', + parameters: {'query': query}, + toolUseId: event.itemId, + ), + ]; + } + if (event.isCompleted) { + return [ + ToolResultResponse( + id: '${event.itemId}_result', + timestamp: DateTime.now(), + toolUseId: event.itemId, + content: 'Search complete', + isError: false, + ), + ]; + } + return []; + } + + List _mapTodoList(ItemEvent event) { + if (!event.isCompleted && !event.isUpdated) return []; + final items = event.data['items'] as List? ?? []; + if (items.isEmpty) return []; + final text = items + .map((item) { + final map = item as Map; + final completed = map['completed'] as bool? ?? false; + final label = map['text'] as String? ?? ''; + return '${completed ? '[x]' : '[ ]'} $label'; + }) + .join('\n'); + return [ + TextResponse( + id: event.itemId, + timestamp: DateTime.now(), + content: text, + isCumulative: true, + ), + ]; + } + + String _inferFileToolName(List? changes) { + if (changes == null || changes.isEmpty) return 'Write'; + final kind = (changes.first as Map)['kind'] as String? ?? ''; + return switch (kind) { + 'add' => 'Write', + 'delete' => 'Write', + 'update' => 'Edit', + _ => 'Write', + }; + } + + /// Extract text from MCP result, which can be a string, a content block + /// array, or a plain map. + String _extractMcpResult(dynamic result) { + if (result == null) return ''; + if (result is String) return result; + if (result is List) { + // Content block array: [{type: "text", text: "..."}, ...] + return result + .map((block) { + if (block is Map && block['type'] == 'text') { + return block['text'] as String? ?? ''; + } + if (block is Map) return block.toString(); + return block.toString(); + }) + .join('\n'); + } + if (result is Map) return result.toString(); + return result.toString(); + } + + /// Extract error message from structured or string error. + String _extractErrorMessage(dynamic error) { + if (error is String) return error; + if (error is Map) return error['message'] as String? ?? error.toString(); + return error.toString(); + } +} diff --git a/packages/codex_sdk/lib/src/protocol/codex_event_parser.dart b/packages/codex_sdk/lib/src/protocol/codex_event_parser.dart new file mode 100644 index 0000000..6bf9562 --- /dev/null +++ b/packages/codex_sdk/lib/src/protocol/codex_event_parser.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'codex_event.dart'; + +/// Parses raw JSONL lines from `codex exec --json` stdout into [CodexEvent]s. +class CodexEventParser { + /// Parse a single JSON line into a [CodexEvent]. + /// Returns null if the line is empty or cannot be parsed. + CodexEvent? parseLine(String line) { + final trimmed = line.trim(); + if (trimmed.isEmpty) return null; + + try { + final json = jsonDecode(trimmed) as Map; + return CodexEvent.fromJson(json); + } catch (_) { + return null; + } + } + + /// Parse a chunk of text that may contain multiple JSONL lines. + List parseChunk(String chunk) { + final events = []; + for (final line in chunk.split('\n')) { + final event = parseLine(line); + if (event != null) { + events.add(event); + } + } + return events; + } +} diff --git a/packages/codex_sdk/pubspec.yaml b/packages/codex_sdk/pubspec.yaml new file mode 100644 index 0000000..6173e5a --- /dev/null +++ b/packages/codex_sdk/pubspec.yaml @@ -0,0 +1,16 @@ +name: codex_sdk +description: Dart wrapper for OpenAI Codex CLI headless mode +version: 0.1.0 +resolution: workspace + +environment: + sdk: ^3.8.0 + +dependencies: + claude_sdk: any + uuid: ^4.5.1 + path: ^1.8.3 + +dev_dependencies: + test: ^1.24.0 + lints: ^3.0.0 diff --git a/pubspec.yaml b/pubspec.yaml index 2da355d..58f71f2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ environment: workspace: - packages/claude_sdk + - packages/codex_sdk - packages/flutter_runtime_mcp - packages/moondream_api - packages/vide_interface From 9dd8ef61d50e11868a30dc5c51cc3c3ad8eccc43 Mon Sep 17 00:00:00 2001 From: Norbert515 Date: Sun, 15 Feb 2026 00:24:52 +0100 Subject: [PATCH 2/3] fix(codex_sdk): fix multi-turn resume, remove dead approval code, add tests - Fix resume mode: prompt passed as positional arg, not stdin - Fix stale process callbacks via turn ID scoping - Add _isClosed guard to prevent post-close stream errors - Remove dead approvalPolicy/fullAuto fields (exec mode ignores them) - Always pass --full-auto (exec mode hardcodes approval_policy: never) - Add 85 unit tests (config, event parser, event mapper, MCP registry) - Add 7 e2e tests including multi-turn resume verification --- .../lib/src/client/codex_client.dart | 35 +- .../lib/src/config/codex_config.dart | 25 +- .../codex_sdk/test/codex_client_e2e_test.dart | 282 +++++++++ .../codex_sdk/test/codex_config_test.dart | 147 +++++ .../test/codex_event_mapper_test.dart | 589 ++++++++++++++++++ .../test/codex_event_parser_test.dart | 217 +++++++ .../test/codex_mcp_registry_test.dart | 53 ++ 7 files changed, 1317 insertions(+), 31 deletions(-) create mode 100644 packages/codex_sdk/test/codex_client_e2e_test.dart create mode 100644 packages/codex_sdk/test/codex_config_test.dart create mode 100644 packages/codex_sdk/test/codex_event_mapper_test.dart create mode 100644 packages/codex_sdk/test/codex_event_parser_test.dart create mode 100644 packages/codex_sdk/test/codex_mcp_registry_test.dart diff --git a/packages/codex_sdk/lib/src/client/codex_client.dart b/packages/codex_sdk/lib/src/client/codex_client.dart index 2855521..b3750a7 100644 --- a/packages/codex_sdk/lib/src/client/codex_client.dart +++ b/packages/codex_sdk/lib/src/client/codex_client.dart @@ -27,7 +27,9 @@ class CodexClient { String? _threadId; Process? _activeProcess; bool _isInitialized = false; + bool _isClosed = false; bool _turnFinished = false; + int _turnId = 0; final Completer _initializedCompleter = Completer(); final String _sessionId; @@ -165,6 +167,7 @@ class CodexClient { } Future close() async { + _isClosed = true; _activeProcess?.kill(ProcessSignal.sigterm); _activeProcess = null; @@ -202,6 +205,7 @@ class CodexClient { Future _runCodexExec(String prompt) async { _turnFinished = false; + final currentTurnId = ++_turnId; final isResume = _threadId != null; final args = codexConfig.toCliArgs( @@ -217,10 +221,10 @@ class CodexClient { ); } - // For non-resume, add the prompt as a positional argument - if (!isResume) { - args.add(prompt); - } + // Prompt is always a positional argument. + // For non-resume: codex exec [FLAGS] + // For resume: codex exec [FLAGS] resume + args.add(prompt); try { final process = await Process.start( @@ -236,27 +240,24 @@ class CodexClient { _activeProcess = process; - // For resume, send the prompt via stdin - if (isResume) { - process.stdin.writeln(prompt); - await process.stdin.flush(); - } - // Process stdout (JSONL events) final stdoutBuffer = StringBuffer(); process.stdout .transform(utf8.decoder) .listen( (chunk) { + if (_turnId != currentTurnId) return; stdoutBuffer.write(chunk); _processChunk(stdoutBuffer); }, onDone: () { + if (_turnId != currentTurnId) return; // Process any remaining data in buffer _processRemainingBuffer(stdoutBuffer); _onProcessDone(); }, onError: (error) { + if (_turnId != currentTurnId) return; _handleProcessError('stdout error: $error'); }, ); @@ -269,6 +270,7 @@ class CodexClient { // Handle process exit process.exitCode.then((exitCode) { + if (_turnId != currentTurnId) return; _activeProcess = null; if (_turnFinished) return; if (exitCode != 0 && stderrBuffer.isNotEmpty) { @@ -337,7 +339,7 @@ class CodexClient { // Handle init data (MetaResponse) if (response is MetaResponse) { _initData = response; - _initDataController.add(response); + if (!_isClosed) _initDataController.add(response); onMetaResponseReceived?.call(response); } @@ -351,12 +353,12 @@ class CodexClient { if (turnComplete) { _updateStatus(ClaudeStatus.ready); - _turnCompleteController.add(null); + if (!_isClosed) _turnCompleteController.add(null); } } void _onProcessDone() { - if (_turnFinished) return; + if (_turnFinished || _isClosed) return; _turnFinished = true; // If we never got a completion event, emit one now if (_currentConversation.isProcessing) { @@ -372,11 +374,12 @@ class CodexClient { ); _updateConversation(result.updatedConversation); _updateStatus(ClaudeStatus.ready); - _turnCompleteController.add(null); + if (!_isClosed) _turnCompleteController.add(null); } } void _handleProcessError(String error) { + if (_isClosed) return; final errorResponse = ErrorResponse( id: 'codex_error_${DateTime.now().millisecondsSinceEpoch}', timestamp: DateTime.now(), @@ -389,15 +392,17 @@ class CodexClient { ); _updateConversation(result.updatedConversation); _updateStatus(ClaudeStatus.ready); - _turnCompleteController.add(null); + if (!_isClosed) _turnCompleteController.add(null); } void _updateConversation(Conversation newConversation) { + if (_isClosed) return; _currentConversation = newConversation; _conversationController.add(_currentConversation); } void _updateStatus(ClaudeStatus status) { + if (_isClosed) return; if (_currentStatus != status) { _currentStatus = status; _statusController.add(status); diff --git a/packages/codex_sdk/lib/src/config/codex_config.dart b/packages/codex_sdk/lib/src/config/codex_config.dart index 8603bb5..8f99c3b 100644 --- a/packages/codex_sdk/lib/src/config/codex_config.dart +++ b/packages/codex_sdk/lib/src/config/codex_config.dart @@ -1,25 +1,23 @@ class CodexConfig { final String? model; final String? profile; - final String approvalPolicy; final String sandboxMode; final String? workingDirectory; final String? sessionId; final String? appendSystemPrompt; final List? additionalFlags; - final bool fullAuto; + final bool skipGitRepoCheck; final List? additionalDirs; const CodexConfig({ this.model, this.profile, - this.approvalPolicy = 'on-request', this.sandboxMode = 'workspace-write', this.workingDirectory, this.sessionId, this.appendSystemPrompt, this.additionalFlags, - this.fullAuto = true, + this.skipGitRepoCheck = false, this.additionalDirs, }); @@ -29,10 +27,7 @@ class CodexConfig { final args = ['exec']; args.add('--json'); - - if (fullAuto) { - args.add('--full-auto'); - } + args.add('--full-auto'); if (model != null) { args.addAll(['--model', model!]); @@ -42,10 +37,6 @@ class CodexConfig { args.addAll(['--profile', profile!]); } - if (!fullAuto) { - args.addAll(['--ask-for-approval', approvalPolicy]); - } - if (sandboxMode != 'workspace-write') { args.addAll(['--sandbox', sandboxMode]); } @@ -54,6 +45,10 @@ class CodexConfig { args.addAll(['-c', 'instructions.append=$appendSystemPrompt']); } + if (skipGitRepoCheck) { + args.add('--skip-git-repo-check'); + } + if (additionalDirs != null) { for (final dir in additionalDirs!) { args.addAll(['--add-dir', dir]); @@ -76,25 +71,23 @@ class CodexConfig { CodexConfig copyWith({ String? model, String? profile, - String? approvalPolicy, String? sandboxMode, String? workingDirectory, String? sessionId, String? appendSystemPrompt, List? additionalFlags, - bool? fullAuto, + bool? skipGitRepoCheck, List? additionalDirs, }) { return CodexConfig( model: model ?? this.model, profile: profile ?? this.profile, - approvalPolicy: approvalPolicy ?? this.approvalPolicy, sandboxMode: sandboxMode ?? this.sandboxMode, workingDirectory: workingDirectory ?? this.workingDirectory, sessionId: sessionId ?? this.sessionId, appendSystemPrompt: appendSystemPrompt ?? this.appendSystemPrompt, additionalFlags: additionalFlags ?? this.additionalFlags, - fullAuto: fullAuto ?? this.fullAuto, + skipGitRepoCheck: skipGitRepoCheck ?? this.skipGitRepoCheck, additionalDirs: additionalDirs ?? this.additionalDirs, ); } diff --git a/packages/codex_sdk/test/codex_client_e2e_test.dart b/packages/codex_sdk/test/codex_client_e2e_test.dart new file mode 100644 index 0000000..a23b668 --- /dev/null +++ b/packages/codex_sdk/test/codex_client_e2e_test.dart @@ -0,0 +1,282 @@ +@Tags(['e2e']) +import 'dart:io'; + +import 'package:claude_sdk/claude_sdk.dart'; +import 'package:codex_sdk/codex_sdk.dart'; +import 'package:test/test.dart'; + +/// End-to-end test that runs a real `codex exec` process. +/// +/// Requires: +/// - `codex` CLI installed and on PATH +/// - Valid OpenAI API key configured +/// +/// Run with: dart test test/codex_client_e2e_test.dart --tags e2e +void main() { + late CodexClient client; + late Directory tempDir; + + setUpAll(() { + final result = Process.runSync('which', ['codex']); + if (result.exitCode != 0) { + fail('codex CLI not found on PATH — skipping e2e tests'); + } + }); + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('codex_e2e_'); + client = CodexClient( + codexConfig: CodexConfig( + workingDirectory: tempDir.path, + skipGitRepoCheck: true, + ), + ); + }); + + tearDown(() async { + await client.close(); + // Small delay to let any dangling async handlers settle + await Future.delayed(const Duration(milliseconds: 200)); + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('sends a simple prompt and receives a complete conversation', () async { + await client.init(); + + // Subscribe to streams BEFORE sending the message to avoid races + final turnFuture = client.onTurnComplete.first; + final statuses = []; + final sub = client.statusStream.listen(statuses.add); + + client.sendMessage( + Message(text: 'Respond with exactly: "hello from codex"'), + ); + + await turnFuture.timeout( + const Duration(seconds: 60), + onTimeout: () => fail('Timed out waiting for turn completion'), + ); + await sub.cancel(); + + final conv = client.currentConversation; + expect(conv.messages, isNotEmpty); + + // First message should be the user message we sent + final userMsg = conv.messages.first; + expect(userMsg.role, MessageRole.user); + expect(userMsg.content, contains('hello from codex')); + + // Should have at least one assistant message + final assistantMessages = conv.messages + .where((m) => m.role == MessageRole.assistant) + .toList(); + expect( + assistantMessages, + isNotEmpty, + reason: 'Expected assistant messages in conversation', + ); + + // The assistant message should have responses with text content + final lastAssistant = assistantMessages.last; + expect( + lastAssistant.responses, + isNotEmpty, + reason: + 'No responses. Types: ${conv.messages.map((m) => '${m.role}: ${m.responses.map((r) => r.runtimeType).toList()}').toList()}', + ); + + final textResponses = lastAssistant.responses + .whereType() + .toList(); + expect( + textResponses, + isNotEmpty, + reason: + 'No TextResponse. Types: ${lastAssistant.responses.map((r) => '${r.runtimeType}(${r.id})').toList()}', + ); + + final allText = textResponses.map((r) => r.content).join(); + expect(allText, isNotEmpty); + + // Status should have gone through processing -> ready + expect(statuses, contains(ClaudeStatus.processing)); + expect(statuses.last, ClaudeStatus.ready); + expect(conv.isProcessing, isFalse); + }); + + test('captures thread ID from thread.started event', () async { + await client.init(); + + final turnFuture = client.onTurnComplete.first; + client.sendMessage(Message(text: 'Say "ok"')); + + await turnFuture.timeout( + const Duration(seconds: 60), + onTimeout: () => fail('Timed out waiting for turn completion'), + ); + + expect( + client.initData, + isNotNull, + reason: 'Expected MetaResponse from thread.started', + ); + expect(client.initData!.metadata['session_id'], isNotEmpty); + }); + + test('status transitions correctly during a turn', () async { + await client.init(); + + final statuses = []; + final sub = client.statusStream.listen(statuses.add); + final turnFuture = client.onTurnComplete.first; + + expect(client.currentStatus, ClaudeStatus.ready); + + client.sendMessage(Message(text: 'Say "test"')); + + // Should immediately transition to processing + expect(client.currentStatus, ClaudeStatus.processing); + + await turnFuture.timeout( + const Duration(seconds: 60), + onTimeout: () => fail('Timed out'), + ); + await sub.cancel(); + + expect(client.currentStatus, ClaudeStatus.ready); + expect(statuses.first, ClaudeStatus.processing); + expect(statuses.last, ClaudeStatus.ready); + }); + + test('abort kills the process and resets status', () async { + await client.init(); + + final processingFuture = client.statusStream + .firstWhere((s) => s == ClaudeStatus.processing) + .timeout( + const Duration(seconds: 10), + onTimeout: () => ClaudeStatus.ready, + ); + + client.sendMessage( + Message( + text: + 'Write a very long essay about the complete history of computing ' + 'from ancient abacuses to quantum computing. Include at least 100 ' + 'detailed paragraphs covering every decade.', + ), + ); + + final status = await processingFuture; + if (status == ClaudeStatus.processing) { + await client.abort(); + expect(client.currentStatus, ClaudeStatus.ready); + } + // If it already finished, that's fine + }); + + test('clearConversation resets state', () async { + await client.init(); + + final turnFuture = client.onTurnComplete.first; + client.sendMessage(Message(text: 'Say "hello"')); + + await turnFuture.timeout( + const Duration(seconds: 60), + onTimeout: () => fail('Timed out'), + ); + + expect(client.currentConversation.messages, isNotEmpty); + + await client.clearConversation(); + expect(client.currentConversation.messages, isEmpty); + }); + + test('multi-turn resume sends follow-up on same thread', () async { + await client.init(); + + // Turn 1: establish a fact + final turn1Future = client.onTurnComplete.first; + client.sendMessage( + Message(text: 'Remember this number: 42. Just say "ok, remembered."'), + ); + + await turn1Future.timeout( + const Duration(seconds: 60), + onTimeout: () => fail('Timed out on turn 1'), + ); + + // Should have captured a thread ID + expect(client.initData, isNotNull); + final threadId = client.initData!.metadata['session_id'] as String; + expect(threadId, isNotEmpty); + + // Turn 2: ask about the fact (resume on same thread) + final turn2Future = client.onTurnComplete.first; + client.sendMessage( + Message(text: 'What number did I just tell you to remember? Reply with just the number.'), + ); + + await turn2Future.timeout( + const Duration(seconds: 60), + onTimeout: () => fail('Timed out on turn 2'), + ); + + // Should have user + assistant messages from both turns + final conv = client.currentConversation; + final userMessages = conv.messages + .where((m) => m.role == MessageRole.user) + .toList(); + expect( + userMessages.length, + greaterThanOrEqualTo(2), + reason: 'Expected at least 2 user messages for multi-turn', + ); + + final assistantMessages = conv.messages + .where((m) => m.role == MessageRole.assistant) + .toList(); + expect( + assistantMessages.length, + greaterThanOrEqualTo(2), + reason: + 'Expected at least 2 assistant messages for multi-turn. ' + 'Messages: ${conv.messages.map((m) => '${m.role}(${m.responses.length} responses)').toList()}', + ); + + // The second assistant response should reference the number + final lastAssistant = assistantMessages.last; + final textResponses = lastAssistant.responses + .whereType() + .toList(); + expect( + textResponses, + isNotEmpty, + reason: + 'No TextResponse in last assistant. ' + 'Response types: ${lastAssistant.responses.map((r) => '${r.runtimeType}(${r.id})').toList()}. ' + 'All messages: ${conv.messages.map((m) => '${m.role}: ${m.responses.map((r) => '${r.runtimeType}').toList()}').toList()}', + ); + + final allText = textResponses.map((r) => r.content).join(); + expect( + allText, + contains('42'), + reason: 'Expected assistant to recall the number 42', + ); + }); + + test('cleans up .codex/config.toml on close', () async { + await client.init(); + + final codexDir = Directory('${tempDir.path}/.codex'); + codexDir.createSync(); + File('${codexDir.path}/config.toml').writeAsStringSync('# test config\n'); + + await client.close(); + + expect(File('${codexDir.path}/config.toml').existsSync(), isFalse); + }); +} diff --git a/packages/codex_sdk/test/codex_config_test.dart b/packages/codex_sdk/test/codex_config_test.dart new file mode 100644 index 0000000..338fe25 --- /dev/null +++ b/packages/codex_sdk/test/codex_config_test.dart @@ -0,0 +1,147 @@ +import 'package:codex_sdk/codex_sdk.dart'; +import 'package:test/test.dart'; + +void main() { + group('CodexConfig.toCliArgs', () { + test('generates minimal args with defaults', () { + const config = CodexConfig(); + final args = config.toCliArgs(); + expect(args, ['exec', '--json', '--full-auto']); + }); + + test('always includes --full-auto', () { + const config = CodexConfig(); + final args = config.toCliArgs(); + expect(args, contains('--full-auto')); + }); + + test('includes model flag', () { + const config = CodexConfig(model: 'o3'); + final args = config.toCliArgs(); + expect(args, contains('--model')); + expect(args[args.indexOf('--model') + 1], 'o3'); + }); + + test('includes profile flag', () { + const config = CodexConfig(profile: 'my-profile'); + final args = config.toCliArgs(); + expect(args, contains('--profile')); + expect(args[args.indexOf('--profile') + 1], 'my-profile'); + }); + + test('includes sandbox mode when not default', () { + const config = CodexConfig(sandboxMode: 'danger-full-access'); + final args = config.toCliArgs(); + expect(args, contains('--sandbox')); + expect(args[args.indexOf('--sandbox') + 1], 'danger-full-access'); + }); + + test('omits sandbox mode when default workspace-write', () { + const config = CodexConfig(sandboxMode: 'workspace-write'); + final args = config.toCliArgs(); + expect(args, isNot(contains('--sandbox'))); + }); + + test('includes system prompt append', () { + const config = CodexConfig(appendSystemPrompt: 'Be concise'); + final args = config.toCliArgs(); + expect(args, contains('-c')); + final cIndex = args.indexOf('-c'); + expect(args[cIndex + 1], 'instructions.append=Be concise'); + }); + + test('includes additional dirs with --add-dir', () { + const config = CodexConfig(additionalDirs: ['/tmp/a', '/tmp/b']); + final args = config.toCliArgs(); + expect(args.where((a) => a == '--add-dir').length, 2); + final firstIdx = args.indexOf('--add-dir'); + expect(args[firstIdx + 1], '/tmp/a'); + final secondIdx = args.indexOf('--add-dir', firstIdx + 1); + expect(args[secondIdx + 1], '/tmp/b'); + }); + + test('includes additional flags', () { + const config = CodexConfig(additionalFlags: ['--verbose', '--debug']); + final args = config.toCliArgs(); + expect(args, containsAll(['--verbose', '--debug'])); + }); + + test('includes --skip-git-repo-check when set', () { + const config = CodexConfig(skipGitRepoCheck: true); + final args = config.toCliArgs(); + expect(args, contains('--skip-git-repo-check')); + }); + + test('omits --skip-git-repo-check by default', () { + const config = CodexConfig(); + final args = config.toCliArgs(); + expect(args, isNot(contains('--skip-git-repo-check'))); + }); + + test('places resume subcommand after all flags', () { + const config = CodexConfig(model: 'o3', additionalFlags: ['--verbose']); + final args = config.toCliArgs( + isResume: true, + resumeThreadId: 'thread_123', + ); + + final resumeIndex = args.indexOf('resume'); + expect(resumeIndex, isNot(-1)); + expect(args[resumeIndex + 1], 'thread_123'); + + // All flags should come before resume + expect(args.indexOf('--model'), lessThan(resumeIndex)); + expect(args.indexOf('--verbose'), lessThan(resumeIndex)); + expect(args.indexOf('--json'), lessThan(resumeIndex)); + }); + + test('does not include resume when isResume is false', () { + const config = CodexConfig(); + final args = config.toCliArgs(isResume: false); + expect(args, isNot(contains('resume'))); + }); + + test('does not include resume when threadId is null', () { + const config = CodexConfig(); + final args = config.toCliArgs(isResume: true, resumeThreadId: null); + expect(args, isNot(contains('resume'))); + }); + + test('starts with exec', () { + const config = CodexConfig(); + final args = config.toCliArgs(); + expect(args.first, 'exec'); + }); + }); + + group('CodexConfig.copyWith', () { + test('copies all fields', () { + const original = CodexConfig( + model: 'o3', + profile: 'test', + sandboxMode: 'danger-full-access', + workingDirectory: '/tmp', + sessionId: 'session_1', + appendSystemPrompt: 'Be brief', + additionalFlags: ['--verbose'], + additionalDirs: ['/data'], + ); + + final copy = original.copyWith(model: 'o4-mini'); + expect(copy.model, 'o4-mini'); + expect(copy.profile, 'test'); + expect(copy.sandboxMode, 'danger-full-access'); + expect(copy.workingDirectory, '/tmp'); + expect(copy.sessionId, 'session_1'); + expect(copy.appendSystemPrompt, 'Be brief'); + expect(copy.additionalFlags, ['--verbose']); + expect(copy.additionalDirs, ['/data']); + }); + + test('preserves values when no overrides given', () { + const original = CodexConfig(model: 'o3'); + final copy = original.copyWith(); + expect(copy.model, 'o3'); + }); + }); +} diff --git a/packages/codex_sdk/test/codex_event_mapper_test.dart b/packages/codex_sdk/test/codex_event_mapper_test.dart new file mode 100644 index 0000000..48aa2af --- /dev/null +++ b/packages/codex_sdk/test/codex_event_mapper_test.dart @@ -0,0 +1,589 @@ +import 'package:claude_sdk/claude_sdk.dart'; +import 'package:codex_sdk/codex_sdk.dart'; +import 'package:test/test.dart'; + +void main() { + late CodexEventMapper mapper; + + setUp(() { + mapper = CodexEventMapper(); + }); + + group('CodexEventMapper.mapEvent', () { + group('thread.started', () { + test('maps to MetaResponse with session_id', () { + final event = ThreadStartedEvent(threadId: 'thread_abc'); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + final meta = responses[0] as MetaResponse; + expect(meta.id, 'thread_abc'); + expect(meta.metadata['session_id'], 'thread_abc'); + }); + }); + + group('turn.started', () { + test('maps to StatusResponse processing', () { + const event = TurnStartedEvent(); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + expect( + (responses[0] as StatusResponse).status, + ClaudeStatus.processing, + ); + }); + }); + + group('turn.completed', () { + test('maps to CompletionResponse with usage', () { + final event = TurnCompletedEvent( + usage: CodexUsage( + inputTokens: 100, + cachedInputTokens: 50, + outputTokens: 200, + ), + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + final completion = responses[0] as CompletionResponse; + expect(completion.stopReason, 'completed'); + expect(completion.inputTokens, 100); + expect(completion.outputTokens, 200); + expect(completion.cacheReadInputTokens, 50); + }); + + test('maps to CompletionResponse without usage', () { + const event = TurnCompletedEvent(); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + final completion = responses[0] as CompletionResponse; + expect(completion.inputTokens, isNull); + expect(completion.outputTokens, isNull); + }); + }); + + group('turn.failed', () { + test('maps to ErrorResponse', () { + const event = TurnFailedEvent( + error: 'rate limit exceeded', + details: {'code': 429}, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + final error = responses[0] as ErrorResponse; + expect(error.error, 'rate limit exceeded'); + expect(error.details, contains('429')); + }); + + test('uses fallback message when error is null', () { + const event = TurnFailedEvent(); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect((responses[0] as ErrorResponse).error, 'Turn failed'); + }); + }); + + group('error event', () { + test('maps to ErrorResponse', () { + const event = CodexErrorEvent(message: 'connection lost'); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + expect((responses[0] as ErrorResponse).error, 'connection lost'); + }); + }); + + group('unknown event', () { + test('maps to empty list', () { + final event = UnknownCodexEvent({'type': 'future.thing'}); + final responses = mapper.mapEvent(event); + expect(responses, isEmpty); + }); + }); + + group('agent_message item', () { + test('maps completed event to TextResponse', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'msg_001', + itemType: 'agent_message', + data: {'text': 'Hello world'}, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + final text = responses[0] as TextResponse; + expect(text.id, 'msg_001'); + expect(text.content, 'Hello world'); + expect(text.isCumulative, isTrue); + }); + + test('returns empty for non-completed event', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'msg_001', + itemType: 'agent_message', + data: {'text': ''}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + + test('returns empty for completed event with empty text', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'msg_001', + itemType: 'agent_message', + data: {'text': ''}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + + test('returns empty for completed event with no text key', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'msg_001', + itemType: 'agent_message', + data: {}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + }); + + group('command_execution item', () { + test('maps started event to ToolUseResponse with Bash', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'cmd_001', + itemType: 'command_execution', + data: {'command': 'ls -la'}, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + final tool = responses[0] as ToolUseResponse; + expect(tool.toolName, 'Bash'); + expect(tool.parameters['command'], 'ls -la'); + expect(tool.toolUseId, 'cmd_001'); + }); + + test('maps completed event to ToolResultResponse', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'cmd_001', + itemType: 'command_execution', + data: { + 'command': 'ls -la', + 'exit_code': 0, + 'aggregated_output': 'file1.dart\nfile2.dart', + }, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + final result = responses[0] as ToolResultResponse; + expect(result.toolUseId, 'cmd_001'); + expect(result.content, 'file1.dart\nfile2.dart'); + expect(result.isError, isFalse); + }); + + test('marks non-zero exit code as error', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'cmd_001', + itemType: 'command_execution', + data: {'exit_code': 1, 'aggregated_output': 'command not found'}, + ); + final responses = mapper.mapEvent(event); + expect((responses[0] as ToolResultResponse).isError, isTrue); + }); + + test('returns empty for updated event', () { + const event = ItemEvent( + eventType: 'item.updated', + itemId: 'cmd_001', + itemType: 'command_execution', + data: {}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + }); + + group('file_change item', () { + test('maps started event with changes to ToolUseResponse', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'file_001', + itemType: 'file_change', + data: { + 'changes': [ + {'path': 'lib/foo.dart', 'kind': 'add'}, + ], + }, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + final tool = responses[0] as ToolUseResponse; + expect(tool.toolName, 'Write'); + expect(tool.parameters['files'], ['lib/foo.dart']); + expect(tool.parameters['kind'], 'add'); + }); + + test('infers Edit tool for update kind', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'file_001', + itemType: 'file_change', + data: { + 'changes': [ + {'path': 'lib/foo.dart', 'kind': 'update'}, + ], + }, + ); + final responses = mapper.mapEvent(event); + expect((responses[0] as ToolUseResponse).toolName, 'Edit'); + }); + + test('infers Write tool for delete kind', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'file_001', + itemType: 'file_change', + data: { + 'changes': [ + {'path': 'lib/old.dart', 'kind': 'delete'}, + ], + }, + ); + final responses = mapper.mapEvent(event); + expect((responses[0] as ToolUseResponse).toolName, 'Write'); + }); + + test('maps completed event to summary ToolResultResponse', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'file_001', + itemType: 'file_change', + data: { + 'changes': [ + {'path': 'lib/foo.dart', 'kind': 'add'}, + {'path': 'lib/bar.dart', 'kind': 'update'}, + ], + }, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + final result = responses[0] as ToolResultResponse; + expect(result.content, 'add: lib/foo.dart\nupdate: lib/bar.dart'); + expect(result.isError, isFalse); + }); + + test('handles started event with no changes', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'file_001', + itemType: 'file_change', + data: {}, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect((responses[0] as ToolUseResponse).toolName, 'Write'); + }); + + test('handles completed event with no changes', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'file_001', + itemType: 'file_change', + data: {}, + ); + final responses = mapper.mapEvent(event); + expect((responses[0] as ToolResultResponse).content, 'Done'); + }); + }); + + group('mcp_tool_call item', () { + test('maps started event to ToolUseResponse with server prefix', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'mcp_001', + itemType: 'mcp_tool_call', + data: { + 'server': 'vide-git', + 'tool': 'gitStatus', + 'arguments': {'detailed': true}, + }, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + final tool = responses[0] as ToolUseResponse; + expect(tool.toolName, 'mcp__vide-git__gitStatus'); + expect(tool.parameters['detailed'], true); + }); + + test('uses tool name alone when server is empty', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'mcp_001', + itemType: 'mcp_tool_call', + data: {'server': '', 'tool': 'someBuiltinTool', 'arguments': {}}, + ); + final responses = mapper.mapEvent(event); + expect((responses[0] as ToolUseResponse).toolName, 'someBuiltinTool'); + }); + + test('handles non-map arguments', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'mcp_001', + itemType: 'mcp_tool_call', + data: {'server': 'test', 'tool': 'myTool', 'arguments': 'not a map'}, + ); + final responses = mapper.mapEvent(event); + expect((responses[0] as ToolUseResponse).parameters, isEmpty); + }); + + test('maps completed event with result to ToolResultResponse', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'mcp_001', + itemType: 'mcp_tool_call', + data: {'result': 'some output'}, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + final result = responses[0] as ToolResultResponse; + expect(result.content, 'some output'); + expect(result.isError, isFalse); + }); + + test('maps completed event with content block array result', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'mcp_001', + itemType: 'mcp_tool_call', + data: { + 'result': [ + {'type': 'text', 'text': 'line 1'}, + {'type': 'text', 'text': 'line 2'}, + ], + }, + ); + final responses = mapper.mapEvent(event); + final result = responses[0] as ToolResultResponse; + expect(result.content, 'line 1\nline 2'); + }); + + test('maps completed event with error', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'mcp_001', + itemType: 'mcp_tool_call', + data: {'error': 'tool not found'}, + ); + final responses = mapper.mapEvent(event); + final result = responses[0] as ToolResultResponse; + expect(result.content, 'tool not found'); + expect(result.isError, isTrue); + }); + + test('maps completed event with structured error', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'mcp_001', + itemType: 'mcp_tool_call', + data: { + 'error': {'message': 'permission denied', 'code': 403}, + }, + ); + final responses = mapper.mapEvent(event); + final result = responses[0] as ToolResultResponse; + expect(result.content, 'permission denied'); + expect(result.isError, isTrue); + }); + + test('returns empty for updated event', () { + const event = ItemEvent( + eventType: 'item.updated', + itemId: 'mcp_001', + itemType: 'mcp_tool_call', + data: {}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + }); + + group('reasoning item', () { + test('maps completed event to TextResponse', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'reason_001', + itemType: 'reasoning', + data: {'text': 'Let me think about this...'}, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + final text = responses[0] as TextResponse; + expect(text.content, 'Let me think about this...'); + expect(text.isCumulative, isTrue); + }); + + test('falls back to summary field', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'reason_001', + itemType: 'reasoning', + data: {'summary': 'Thinking summary'}, + ); + final responses = mapper.mapEvent(event); + expect((responses[0] as TextResponse).content, 'Thinking summary'); + }); + + test('returns empty for non-completed event', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'reason_001', + itemType: 'reasoning', + data: {}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + + test('returns empty for empty text', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'reason_001', + itemType: 'reasoning', + data: {'text': ''}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + }); + + group('web_search item', () { + test('maps started event to WebSearch ToolUseResponse', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'search_001', + itemType: 'web_search', + data: {'query': 'dart async patterns'}, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + final tool = responses[0] as ToolUseResponse; + expect(tool.toolName, 'WebSearch'); + expect(tool.parameters['query'], 'dart async patterns'); + }); + + test('maps completed event to ToolResultResponse', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'search_001', + itemType: 'web_search', + data: {}, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + final result = responses[0] as ToolResultResponse; + expect(result.content, 'Search complete'); + expect(result.isError, isFalse); + }); + + test('returns empty for updated event', () { + const event = ItemEvent( + eventType: 'item.updated', + itemId: 'search_001', + itemType: 'web_search', + data: {}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + }); + + group('todo_list item', () { + test('maps completed event to checklist TextResponse', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'todo_001', + itemType: 'todo_list', + data: { + 'items': [ + {'text': 'Write tests', 'completed': true}, + {'text': 'Fix bugs', 'completed': false}, + {'text': 'Deploy', 'completed': false}, + ], + }, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + final text = responses[0] as TextResponse; + expect(text.content, '[x] Write tests\n[ ] Fix bugs\n[ ] Deploy'); + expect(text.isCumulative, isTrue); + }); + + test('maps updated event to checklist TextResponse', () { + const event = ItemEvent( + eventType: 'item.updated', + itemId: 'todo_001', + itemType: 'todo_list', + data: { + 'items': [ + {'text': 'Write tests', 'completed': true}, + ], + }, + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect((responses[0] as TextResponse).content, '[x] Write tests'); + }); + + test('returns empty for started event', () { + const event = ItemEvent( + eventType: 'item.started', + itemId: 'todo_001', + itemType: 'todo_list', + data: {'items': []}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + + test('returns empty for empty items list', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'todo_001', + itemType: 'todo_list', + data: {'items': []}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + }); + + group('unknown item type', () { + test('returns empty list', () { + const event = ItemEvent( + eventType: 'item.completed', + itemId: 'x_001', + itemType: 'unknown_future_type', + data: {}, + ); + expect(mapper.mapEvent(event), isEmpty); + }); + }); + }); + + group('ID generation', () { + test('generates unique IDs across events', () { + final ids = {}; + for (var i = 0; i < 5; i++) { + const event = TurnStartedEvent(); + final responses = mapper.mapEvent(event); + ids.add(responses[0].id); + } + expect(ids, hasLength(5)); + }); + }); +} diff --git a/packages/codex_sdk/test/codex_event_parser_test.dart b/packages/codex_sdk/test/codex_event_parser_test.dart new file mode 100644 index 0000000..439fdc6 --- /dev/null +++ b/packages/codex_sdk/test/codex_event_parser_test.dart @@ -0,0 +1,217 @@ +import 'dart:convert'; + +import 'package:codex_sdk/codex_sdk.dart'; +import 'package:test/test.dart'; + +void main() { + late CodexEventParser parser; + + setUp(() { + parser = CodexEventParser(); + }); + + group('CodexEventParser.parseLine', () { + test('returns null for empty string', () { + expect(parser.parseLine(''), isNull); + }); + + test('returns null for whitespace-only string', () { + expect(parser.parseLine(' \t '), isNull); + }); + + test('returns null for invalid JSON', () { + expect(parser.parseLine('not json at all'), isNull); + }); + + test('returns null for JSON array (not object)', () { + expect(parser.parseLine('[1, 2, 3]'), isNull); + }); + + test('parses thread.started event', () { + final json = jsonEncode({ + 'type': 'thread.started', + 'thread_id': 'thread_abc123', + }); + final event = parser.parseLine(json); + expect(event, isA()); + expect((event as ThreadStartedEvent).threadId, 'thread_abc123'); + }); + + test('parses turn.started event', () { + final json = jsonEncode({'type': 'turn.started'}); + final event = parser.parseLine(json); + expect(event, isA()); + }); + + test('parses turn.completed event with usage', () { + final json = jsonEncode({ + 'type': 'turn.completed', + 'usage': { + 'input_tokens': 100, + 'cached_input_tokens': 50, + 'output_tokens': 200, + }, + }); + final event = parser.parseLine(json); + expect(event, isA()); + final completed = event as TurnCompletedEvent; + expect(completed.usage!.inputTokens, 100); + expect(completed.usage!.cachedInputTokens, 50); + expect(completed.usage!.outputTokens, 200); + }); + + test('parses turn.completed event without usage', () { + final json = jsonEncode({'type': 'turn.completed'}); + final event = parser.parseLine(json); + expect(event, isA()); + expect((event as TurnCompletedEvent).usage, isNull); + }); + + test('parses turn.failed with string error', () { + final json = jsonEncode({ + 'type': 'turn.failed', + 'error': 'something went wrong', + }); + final event = parser.parseLine(json); + expect(event, isA()); + final failed = event as TurnFailedEvent; + expect(failed.error, 'something went wrong'); + expect(failed.details, isNull); + }); + + test('parses turn.failed with structured error', () { + final json = jsonEncode({ + 'type': 'turn.failed', + 'error': {'message': 'rate limit', 'code': 429}, + }); + final event = parser.parseLine(json); + expect(event, isA()); + final failed = event as TurnFailedEvent; + expect(failed.error, 'rate limit'); + expect(failed.details!['code'], 429); + }); + + test('parses item.started event', () { + final json = jsonEncode({ + 'type': 'item.started', + 'item': { + 'id': 'item_001', + 'type': 'agent_message', + 'status': 'in_progress', + 'text': '', + }, + }); + final event = parser.parseLine(json); + expect(event, isA()); + final item = event as ItemEvent; + expect(item.eventType, 'item.started'); + expect(item.itemId, 'item_001'); + expect(item.itemType, 'agent_message'); + expect(item.isStarted, isTrue); + expect(item.isCompleted, isFalse); + expect(item.isUpdated, isFalse); + }); + + test('parses item.completed event', () { + final json = jsonEncode({ + 'type': 'item.completed', + 'item': { + 'id': 'item_001', + 'type': 'agent_message', + 'status': 'completed', + 'text': 'Hello!', + }, + }); + final event = parser.parseLine(json); + expect(event, isA()); + final item = event as ItemEvent; + expect(item.isCompleted, isTrue); + expect(item.data['text'], 'Hello!'); + }); + + test('parses item.updated event', () { + final json = jsonEncode({ + 'type': 'item.updated', + 'item': {'id': 'item_001', 'type': 'todo_list', 'items': []}, + }); + final event = parser.parseLine(json); + expect(event, isA()); + expect((event as ItemEvent).isUpdated, isTrue); + }); + + test('parses error event with string message', () { + final json = jsonEncode({'type': 'error', 'error': 'connection failed'}); + final event = parser.parseLine(json); + expect(event, isA()); + expect((event as CodexErrorEvent).message, 'connection failed'); + }); + + test('parses error event with structured error', () { + final json = jsonEncode({ + 'type': 'error', + 'error': {'message': 'timeout', 'code': 504}, + }); + final event = parser.parseLine(json); + expect(event, isA()); + final error = event as CodexErrorEvent; + expect(error.message, 'timeout'); + expect(error.details!['code'], 504); + }); + + test('parses unknown event type', () { + final json = jsonEncode({'type': 'future.event', 'data': 42}); + final event = parser.parseLine(json); + expect(event, isA()); + expect((event as UnknownCodexEvent).rawData['type'], 'future.event'); + }); + + test('trims whitespace before parsing', () { + final json = ' ${jsonEncode({'type': 'turn.started'})} '; + final event = parser.parseLine(json); + expect(event, isA()); + }); + }); + + group('CodexEventParser.parseChunk', () { + test('parses multiple lines', () { + final chunk = [ + jsonEncode({'type': 'thread.started', 'thread_id': 't1'}), + jsonEncode({'type': 'turn.started'}), + jsonEncode({'type': 'turn.completed'}), + ].join('\n'); + + final events = parser.parseChunk(chunk); + expect(events, hasLength(3)); + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + }); + + test('skips empty lines in chunk', () { + final chunk = [ + jsonEncode({'type': 'turn.started'}), + '', + '', + jsonEncode({'type': 'turn.completed'}), + ].join('\n'); + + final events = parser.parseChunk(chunk); + expect(events, hasLength(2)); + }); + + test('skips unparseable lines in chunk', () { + final chunk = [ + jsonEncode({'type': 'turn.started'}), + 'not json', + jsonEncode({'type': 'turn.completed'}), + ].join('\n'); + + final events = parser.parseChunk(chunk); + expect(events, hasLength(2)); + }); + + test('returns empty list for empty chunk', () { + expect(parser.parseChunk(''), isEmpty); + }); + }); +} diff --git a/packages/codex_sdk/test/codex_mcp_registry_test.dart b/packages/codex_sdk/test/codex_mcp_registry_test.dart new file mode 100644 index 0000000..5be1223 --- /dev/null +++ b/packages/codex_sdk/test/codex_mcp_registry_test.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:codex_sdk/codex_sdk.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('codex_mcp_test_'); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + group('CodexMcpRegistry.writeConfig', () { + test('does nothing when mcpServers is empty', () async { + await CodexMcpRegistry.writeConfig( + mcpServers: [], + workingDirectory: tempDir.path, + ); + + final codexDir = Directory('${tempDir.path}/.codex'); + expect(codexDir.existsSync(), isFalse); + }); + }); + + group('CodexMcpRegistry.cleanUp', () { + test('removes config.toml if it exists', () async { + final codexDir = Directory('${tempDir.path}/.codex'); + codexDir.createSync(); + final configFile = File('${codexDir.path}/config.toml'); + configFile.writeAsStringSync('# test config'); + expect(configFile.existsSync(), isTrue); + + await CodexMcpRegistry.cleanUp(workingDirectory: tempDir.path); + expect(configFile.existsSync(), isFalse); + }); + + test('does nothing if config.toml does not exist', () async { + // Should not throw + await CodexMcpRegistry.cleanUp(workingDirectory: tempDir.path); + }); + + test('does nothing if .codex dir does not exist', () async { + await CodexMcpRegistry.cleanUp(workingDirectory: tempDir.path); + expect(Directory('${tempDir.path}/.codex').existsSync(), isFalse); + }); + }); +} From f9af320aaabc14fa2e609a23aa8f7c2d4fa1baa4 Mon Sep 17 00:00:00 2001 From: Norbert515 Date: Sun, 15 Feb 2026 14:10:24 +0100 Subject: [PATCH 3/3] refactor(codex_sdk): switch from codex exec to codex app-server Replace process-per-turn architecture with persistent JSON-RPC subprocess. This enables interactive approval handling, streaming text deltas, and persistent sessions without per-turn overhead. - Add CodexTransport for persistent subprocess management and JSON-RPC I/O - Add JSON-RPC message types (notification, request, response) - Add approval types (CodexApprovalRequest, CodexApprovalDecision) - Rewrite CodexEvent as sealed class built from JSON-RPC notifications - Rewrite CodexClient with initialize handshake, thread/turn lifecycle - Replace toCliArgs() with toThreadStartParams(), add approvalPolicy - Handle list-based content blocks in reasoning summary fields - Update all unit and e2e tests --- packages/codex_sdk/lib/codex_sdk.dart | 5 +- .../lib/src/client/codex_client.dart | 296 ++++++------ .../lib/src/config/codex_config.dart | 55 +-- .../lib/src/protocol/codex_approval.dart | 124 +++++ .../lib/src/protocol/codex_event.dart | 406 ++++++++++++---- .../lib/src/protocol/codex_event_mapper.dart | 447 +++++++++++------- .../lib/src/protocol/codex_event_parser.dart | 35 +- .../lib/src/protocol/json_rpc_message.dart | 174 +++++++ .../lib/src/transport/codex_transport.dart | 216 +++++++++ .../codex_sdk/test/codex_client_e2e_test.dart | 43 +- .../codex_sdk/test/codex_config_test.dart | 146 +++--- .../test/codex_event_mapper_test.dart | 445 ++++++++--------- .../test/codex_event_parser_test.dart | 344 +++++++------- .../codex_sdk/test/json_rpc_message_test.dart | 121 +++++ 14 files changed, 1845 insertions(+), 1012 deletions(-) create mode 100644 packages/codex_sdk/lib/src/protocol/codex_approval.dart create mode 100644 packages/codex_sdk/lib/src/protocol/json_rpc_message.dart create mode 100644 packages/codex_sdk/lib/src/transport/codex_transport.dart create mode 100644 packages/codex_sdk/test/json_rpc_message_test.dart diff --git a/packages/codex_sdk/lib/codex_sdk.dart b/packages/codex_sdk/lib/codex_sdk.dart index 320bc6e..3a2356d 100644 --- a/packages/codex_sdk/lib/codex_sdk.dart +++ b/packages/codex_sdk/lib/codex_sdk.dart @@ -3,6 +3,9 @@ library codex_sdk; export 'src/client/codex_client.dart'; export 'src/config/codex_config.dart'; export 'src/config/codex_mcp_registry.dart'; +export 'src/protocol/codex_approval.dart'; export 'src/protocol/codex_event.dart'; -export 'src/protocol/codex_event_parser.dart'; export 'src/protocol/codex_event_mapper.dart'; +export 'src/protocol/codex_event_parser.dart'; +export 'src/protocol/json_rpc_message.dart'; +export 'src/transport/codex_transport.dart'; diff --git a/packages/codex_sdk/lib/src/client/codex_client.dart b/packages/codex_sdk/lib/src/client/codex_client.dart index b3750a7..e549e43 100644 --- a/packages/codex_sdk/lib/src/client/codex_client.dart +++ b/packages/codex_sdk/lib/src/client/codex_client.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:claude_sdk/claude_sdk.dart'; @@ -7,14 +6,18 @@ import 'package:uuid/uuid.dart'; import '../config/codex_config.dart'; import '../config/codex_mcp_registry.dart'; +import '../protocol/codex_approval.dart'; import '../protocol/codex_event.dart'; import '../protocol/codex_event_mapper.dart'; import '../protocol/codex_event_parser.dart'; +import '../protocol/json_rpc_message.dart'; +import '../transport/codex_transport.dart'; -/// Standalone client backed by Codex CLI (`codex exec --json`). +/// Standalone client backed by Codex CLI (`codex app-server`). /// -/// Each turn spawns a new `codex exec` process (or `codex exec resume ` -/// for multi-turn). The JSONL events are parsed, mapped to [ClaudeResponse] +/// Uses a persistent subprocess communicating via JSON-RPC over stdin/stdout. +/// The transport layer handles message framing and request correlation. +/// Notifications are parsed into [CodexEvent]s, mapped to [ClaudeResponse] /// objects, and fed through [ResponseProcessor] so the entire downstream /// pipeline (Conversation, TUI, vide_server) works unchanged. class CodexClient { @@ -24,12 +27,11 @@ class CodexClient { final CodexEventParser _parser = CodexEventParser(); final CodexEventMapper _mapper = CodexEventMapper(); + final CodexTransport _transport = CodexTransport(); + String? _threadId; - Process? _activeProcess; bool _isInitialized = false; bool _isClosed = false; - bool _turnFinished = false; - int _turnId = 0; final Completer _initializedCompleter = Completer(); final String _sessionId; @@ -41,6 +43,8 @@ class CodexClient { final _statusController = StreamController.broadcast(); final _initDataController = StreamController.broadcast(); final _queuedMessageController = StreamController.broadcast(); + final _approvalRequestController = + StreamController.broadcast(); Conversation _currentConversation = Conversation.empty(); ClaudeStatus _currentStatus = ClaudeStatus.ready; @@ -62,7 +66,8 @@ class CodexClient { }); } - /// Initialize the client: start MCP servers and mark as ready. + /// Initialize the client: start MCP servers, launch app-server, handshake, + /// start a thread, and wait for MCP startup to complete. Future init() async { if (_isInitialized) return; @@ -73,6 +78,71 @@ class CodexClient { } } + // Write MCP config before starting the app-server + if (mcpServers.isNotEmpty) { + await CodexMcpRegistry.writeConfig( + mcpServers: mcpServers, + workingDirectory: _workingDirectory, + ); + } + + // Start the persistent subprocess + await _transport.start(workingDirectory: _workingDirectory); + + // Subscribe to notifications → event pipeline + _transport.notifications.listen(_onNotification); + + // Subscribe to server requests → approval pipeline + _transport.serverRequests.listen(_onServerRequest); + + // Initialize handshake + final initResponse = await _transport.sendRequest('initialize', { + 'clientInfo': { + 'name': 'vide', + 'version': '0.1.0', + 'title': 'Vide', + }, + 'capabilities': {'experimentalApi': true}, + }); + + if (initResponse.isError) { + throw StateError( + 'Codex initialize failed: ${initResponse.error?.message}', + ); + } + + // Send initialized notification + _transport.sendNotification('initialized'); + + // Start a thread + final threadResponse = await _transport.sendRequest( + 'thread/start', + codexConfig.toThreadStartParams(), + ); + + if (threadResponse.isError) { + throw StateError( + 'Codex thread/start failed: ${threadResponse.error?.message}', + ); + } + + final threadResult = threadResponse.result ?? {}; + final thread = threadResult['thread'] as Map? ?? {}; + _threadId = thread['id'] as String?; + + // Wait for mcp_startup_complete notification + if (mcpServers.isNotEmpty) { + await _transport.notifications + .where((n) => n.method == 'codex/event/mcp_startup_complete') + .first + .timeout( + const Duration(seconds: 30), + onTimeout: () => throw TimeoutException( + 'Timed out waiting for MCP startup to complete', + ), + ); + } + _isInitialized = true; if (!_initializedCompleter.isCompleted) { _initializedCompleter.complete(); @@ -87,6 +157,8 @@ class CodexClient { String get workingDirectory => _workingDirectory; + String? get threadId => _threadId; + Stream get conversation => _conversationController.stream; Conversation get currentConversation => _currentConversation; @@ -107,6 +179,10 @@ class CodexClient { String? get currentQueuedMessage => _queuedMessageText; + /// Approval requests from the server. + Stream get approvalRequests => + _approvalRequestController.stream; + void clearQueuedMessage() { _queuedMessageText = null; _queuedAttachments = null; @@ -114,7 +190,8 @@ class CodexClient { } void sendMessage(Message message) { - if (message.text.trim().isEmpty && (message.attachments?.isEmpty ?? true)) { + if (message.text.trim().isEmpty && + (message.attachments?.isEmpty ?? true)) { return; } @@ -137,8 +214,15 @@ class CodexClient { _updateStatus(ClaudeStatus.processing); - // Spawn codex exec process - _runCodexExec(message.text); + _startTurn(message.text); + } + + /// Respond to a server approval request. + void respondToApproval( + dynamic requestId, + CodexApprovalDecision decision, + ) { + _transport.respondToRequest(requestId, {'decision': decision.toJson()}); } void injectToolResult(ToolResultResponse toolResult) { @@ -161,15 +245,17 @@ class CodexClient { } Future abort() async { - _activeProcess?.kill(ProcessSignal.sigint); - _activeProcess = null; + if (!_transport.isRunning || _threadId == null) return; + await _transport.sendRequest('turn/interrupt', { + 'threadId': _threadId, + }); _updateStatus(ClaudeStatus.ready); } Future close() async { _isClosed = true; - _activeProcess?.kill(ProcessSignal.sigterm); - _activeProcess = null; + + await _transport.close(); for (final server in mcpServers) { await server.stop(); @@ -182,13 +268,25 @@ class CodexClient { await _statusController.close(); await _initDataController.close(); await _queuedMessageController.close(); + await _approvalRequestController.close(); _isInitialized = false; } Future clearConversation() async { _updateConversation(Conversation.empty()); - _threadId = null; + + // Start a new thread on the same persistent server + final threadResponse = await _transport.sendRequest( + 'thread/start', + codexConfig.toThreadStartParams(), + ); + + if (!threadResponse.isError) { + final threadResult = threadResponse.result ?? {}; + final thread = threadResult['thread'] as Map? ?? {}; + _threadId = thread['id'] as String?; + } } T? getMcpServer(String name) { @@ -203,123 +301,55 @@ class CodexClient { // Private implementation // ============================================================ - Future _runCodexExec(String prompt) async { - _turnFinished = false; - final currentTurnId = ++_turnId; - final isResume = _threadId != null; - - final args = codexConfig.toCliArgs( - isResume: isResume, - resumeThreadId: _threadId, - ); - - // Write MCP config if we have servers - if (mcpServers.isNotEmpty) { - await CodexMcpRegistry.writeConfig( - mcpServers: mcpServers, - workingDirectory: _workingDirectory, - ); - } - - // Prompt is always a positional argument. - // For non-resume: codex exec [FLAGS] - // For resume: codex exec [FLAGS] resume - args.add(prompt); + Future _startTurn(String prompt) async { + final turnResponse = await _transport.sendRequest('turn/start', { + 'threadId': _threadId, + 'input': [ + {'type': 'text', 'text': prompt}, + ], + }); - try { - final process = await Process.start( - 'codex', - args, - workingDirectory: _workingDirectory, - environment: { - ...Platform.environment, - // Ensure JSON output even if config doesn't set it - 'CODEX_OUTPUT_FORMAT': 'json', - }, + if (turnResponse.isError) { + _handleError( + 'turn/start failed: ${turnResponse.error?.message ?? 'unknown'}', ); - - _activeProcess = process; - - // Process stdout (JSONL events) - final stdoutBuffer = StringBuffer(); - process.stdout - .transform(utf8.decoder) - .listen( - (chunk) { - if (_turnId != currentTurnId) return; - stdoutBuffer.write(chunk); - _processChunk(stdoutBuffer); - }, - onDone: () { - if (_turnId != currentTurnId) return; - // Process any remaining data in buffer - _processRemainingBuffer(stdoutBuffer); - _onProcessDone(); - }, - onError: (error) { - if (_turnId != currentTurnId) return; - _handleProcessError('stdout error: $error'); - }, - ); - - // Capture stderr for error reporting - final stderrBuffer = StringBuffer(); - process.stderr.transform(utf8.decoder).listen((chunk) { - stderrBuffer.write(chunk); - }); - - // Handle process exit - process.exitCode.then((exitCode) { - if (_turnId != currentTurnId) return; - _activeProcess = null; - if (_turnFinished) return; - if (exitCode != 0 && stderrBuffer.isNotEmpty) { - _handleProcessError( - 'Codex exited with code $exitCode: ${stderrBuffer.toString()}', - ); - } - }); - } catch (e) { - _handleProcessError('Failed to start codex: $e'); } } - void _processChunk(StringBuffer buffer) { - // Extract complete lines from the buffer - final content = buffer.toString(); - final lines = content.split('\n'); - - // Keep the last incomplete line in the buffer - buffer.clear(); - if (!content.endsWith('\n') && lines.isNotEmpty) { - buffer.write(lines.removeLast()); - } else if (lines.isNotEmpty && lines.last.isEmpty) { - lines.removeLast(); - } - - for (final line in lines) { - if (line.trim().isEmpty) continue; - - final event = _parser.parseLine(line); - if (event == null) continue; + void _onNotification(JsonRpcNotification notification) { + if (_isClosed) return; - _handleEvent(event); - } + final event = _parser.parseNotification(notification); + _handleEvent(event); } - void _processRemainingBuffer(StringBuffer buffer) { - final remaining = buffer.toString().trim(); - if (remaining.isEmpty) return; + void _onServerRequest(JsonRpcRequest request) { + if (_isClosed) return; - final event = _parser.parseLine(remaining); - if (event != null) { - _handleEvent(event); + switch (request.method) { + case 'item/commandExecution/requestApproval': + final approval = CodexApprovalRequest.commandExecution( + requestId: request.id, + params: request.params, + ); + _approvalRequestController.add(approval); + case 'item/fileChange/requestApproval': + final approval = CodexApprovalRequest.fileChange( + requestId: request.id, + params: request.params, + ); + _approvalRequestController.add(approval); + case 'item/tool/requestUserInput': + final approval = CodexApprovalRequest.userInput( + requestId: request.id, + params: request.params, + ); + _approvalRequestController.add(approval); } - buffer.clear(); } void _handleEvent(CodexEvent event) { - // Capture thread ID from thread.started + // Capture thread ID from thread/started notification if (event is ThreadStartedEvent) { _threadId = event.threadId; } @@ -344,7 +374,8 @@ class CodexClient { } // Process through ResponseProcessor - final result = _responseProcessor.processResponse(response, conversation); + final result = + _responseProcessor.processResponse(response, conversation); conversation = result.updatedConversation; turnComplete = turnComplete || result.turnComplete; } @@ -357,28 +388,7 @@ class CodexClient { } } - void _onProcessDone() { - if (_turnFinished || _isClosed) return; - _turnFinished = true; - // If we never got a completion event, emit one now - if (_currentConversation.isProcessing) { - final completionResponse = CompletionResponse( - id: 'codex_done_${DateTime.now().millisecondsSinceEpoch}', - timestamp: DateTime.now(), - stopReason: 'completed', - ); - - final result = _responseProcessor.processResponse( - completionResponse, - _currentConversation, - ); - _updateConversation(result.updatedConversation); - _updateStatus(ClaudeStatus.ready); - if (!_isClosed) _turnCompleteController.add(null); - } - } - - void _handleProcessError(String error) { + void _handleError(String error) { if (_isClosed) return; final errorResponse = ErrorResponse( id: 'codex_error_${DateTime.now().millisecondsSinceEpoch}', diff --git a/packages/codex_sdk/lib/src/config/codex_config.dart b/packages/codex_sdk/lib/src/config/codex_config.dart index 8f99c3b..9b9118d 100644 --- a/packages/codex_sdk/lib/src/config/codex_config.dart +++ b/packages/codex_sdk/lib/src/config/codex_config.dart @@ -5,9 +5,9 @@ class CodexConfig { final String? workingDirectory; final String? sessionId; final String? appendSystemPrompt; - final List? additionalFlags; final bool skipGitRepoCheck; final List? additionalDirs; + final String approvalPolicy; const CodexConfig({ this.model, @@ -16,56 +16,31 @@ class CodexConfig { this.workingDirectory, this.sessionId, this.appendSystemPrompt, - this.additionalFlags, this.skipGitRepoCheck = false, this.additionalDirs, + this.approvalPolicy = 'on-failure', }); - List toCliArgs({bool isResume = false, String? resumeThreadId}) { - // Codex CLI expects: codex exec [FLAGS] [resume ] [prompt] - // All flags must come before the resume subcommand. - final args = ['exec']; + /// Build the params map for the `thread/start` JSON-RPC request. + Map toThreadStartParams() { + final params = {}; - args.add('--json'); - args.add('--full-auto'); - - if (model != null) { - args.addAll(['--model', model!]); + if (workingDirectory != null) { + params['cwd'] = workingDirectory; } - if (profile != null) { - args.addAll(['--profile', profile!]); - } + params['sandbox'] = sandboxMode; + params['approvalPolicy'] = approvalPolicy; - if (sandboxMode != 'workspace-write') { - args.addAll(['--sandbox', sandboxMode]); + if (model != null) { + params['model'] = model; } if (appendSystemPrompt != null) { - args.addAll(['-c', 'instructions.append=$appendSystemPrompt']); - } - - if (skipGitRepoCheck) { - args.add('--skip-git-repo-check'); - } - - if (additionalDirs != null) { - for (final dir in additionalDirs!) { - args.addAll(['--add-dir', dir]); - } - } - - if (additionalFlags != null) { - args.addAll(additionalFlags!); - } - - // resume subcommand goes after all flags - if (isResume && resumeThreadId != null) { - args.add('resume'); - args.add(resumeThreadId); + params['developerInstructions'] = appendSystemPrompt; } - return args; + return params; } CodexConfig copyWith({ @@ -75,9 +50,9 @@ class CodexConfig { String? workingDirectory, String? sessionId, String? appendSystemPrompt, - List? additionalFlags, bool? skipGitRepoCheck, List? additionalDirs, + String? approvalPolicy, }) { return CodexConfig( model: model ?? this.model, @@ -86,9 +61,9 @@ class CodexConfig { workingDirectory: workingDirectory ?? this.workingDirectory, sessionId: sessionId ?? this.sessionId, appendSystemPrompt: appendSystemPrompt ?? this.appendSystemPrompt, - additionalFlags: additionalFlags ?? this.additionalFlags, skipGitRepoCheck: skipGitRepoCheck ?? this.skipGitRepoCheck, additionalDirs: additionalDirs ?? this.additionalDirs, + approvalPolicy: approvalPolicy ?? this.approvalPolicy, ); } } diff --git a/packages/codex_sdk/lib/src/protocol/codex_approval.dart b/packages/codex_sdk/lib/src/protocol/codex_approval.dart new file mode 100644 index 0000000..c469080 --- /dev/null +++ b/packages/codex_sdk/lib/src/protocol/codex_approval.dart @@ -0,0 +1,124 @@ +/// Approval types for the Codex app-server. +/// +/// When the approval policy allows it, the server sends JSON-RPC requests +/// to the client asking for permission before executing commands or +/// modifying files. The client must respond with a decision. + +/// An approval request from the Codex server. +class CodexApprovalRequest { + /// The JSON-RPC request ID. Must be echoed back in the response. + final dynamic requestId; + + /// What type of approval is being requested. + final CodexApprovalType type; + + /// Thread, turn, and item context. + final String threadId; + final String turnId; + final String itemId; + + /// The command to be executed (for command approvals). + final String? command; + + /// The command's working directory. + final String? cwd; + + /// Reason for the approval request. + final String? reason; + + /// Proposed exec-policy amendment to auto-approve similar commands. + final List? proposedExecpolicyAmendment; + + /// Grant root path for file change approvals. + final String? grantRoot; + + /// Questions for user input requests. + final List>? questions; + + const CodexApprovalRequest({ + required this.requestId, + required this.type, + required this.threadId, + required this.turnId, + required this.itemId, + this.command, + this.cwd, + this.reason, + this.proposedExecpolicyAmendment, + this.grantRoot, + this.questions, + }); + + factory CodexApprovalRequest.commandExecution({ + required dynamic requestId, + required Map params, + }) { + return CodexApprovalRequest( + requestId: requestId, + type: CodexApprovalType.commandExecution, + threadId: params['threadId'] as String? ?? '', + turnId: params['turnId'] as String? ?? '', + itemId: params['itemId'] as String? ?? '', + command: params['command'] as String?, + cwd: params['cwd'] as String?, + reason: params['reason'] as String?, + proposedExecpolicyAmendment: + (params['proposedExecpolicyAmendment'] as List?) + ?.cast(), + ); + } + + factory CodexApprovalRequest.fileChange({ + required dynamic requestId, + required Map params, + }) { + return CodexApprovalRequest( + requestId: requestId, + type: CodexApprovalType.fileChange, + threadId: params['threadId'] as String? ?? '', + turnId: params['turnId'] as String? ?? '', + itemId: params['itemId'] as String? ?? '', + reason: params['reason'] as String?, + grantRoot: params['grantRoot'] as String?, + ); + } + + factory CodexApprovalRequest.userInput({ + required dynamic requestId, + required Map params, + }) { + return CodexApprovalRequest( + requestId: requestId, + type: CodexApprovalType.userInput, + threadId: params['threadId'] as String? ?? '', + turnId: params['turnId'] as String? ?? '', + itemId: params['itemId'] as String? ?? '', + questions: (params['questions'] as List?) + ?.cast>(), + ); + } +} + +/// The type of approval being requested. +enum CodexApprovalType { + commandExecution, + fileChange, + userInput, +} + +/// Decision for a command or file change approval request. +enum CodexApprovalDecision { + /// Approve this specific request. + accept, + + /// Approve and auto-approve identical requests for the session. + acceptForSession, + + /// Deny the request. The agent continues the turn. + decline, + + /// Deny the request and interrupt the turn. + cancel; + + String toJson() => name; +} diff --git a/packages/codex_sdk/lib/src/protocol/codex_event.dart b/packages/codex_sdk/lib/src/protocol/codex_event.dart index 5b92a17..53e0edd 100644 --- a/packages/codex_sdk/lib/src/protocol/codex_event.dart +++ b/packages/codex_sdk/lib/src/protocol/codex_event.dart @@ -1,137 +1,377 @@ -/// Codex CLI JSONL event types. +import 'json_rpc_message.dart'; + +/// Codex app-server event types. /// -/// These represent the structured events emitted by `codex exec --json`. +/// These represent the JSON-RPC notifications sent by `codex app-server`. +/// Each event corresponds to a notification method (e.g. `item/started`, +/// `item/agentMessage/delta`). sealed class CodexEvent { const CodexEvent(); - factory CodexEvent.fromJson(Map json) { - final type = json['type'] as String? ?? ''; - return switch (type) { - 'thread.started' => ThreadStartedEvent.fromJson(json), - 'turn.started' => const TurnStartedEvent(), - 'turn.completed' => TurnCompletedEvent.fromJson(json), - 'turn.failed' => TurnFailedEvent.fromJson(json), - 'item.started' || - 'item.updated' || - 'item.completed' => ItemEvent.fromJson(type, json), - 'error' => CodexErrorEvent.fromJson(json), - _ => UnknownCodexEvent(json), + /// Parse a [JsonRpcNotification] into a typed [CodexEvent]. + factory CodexEvent.fromNotification(JsonRpcNotification notification) { + final method = notification.method; + final params = notification.params; + + return switch (method) { + // Thread lifecycle + 'thread/started' => ThreadStartedEvent.fromParams(params), + 'thread/name/updated' => ThreadNameUpdatedEvent.fromParams(params), + 'thread/tokenUsage/updated' => + TokenUsageUpdatedEvent.fromParams(params), + 'thread/compacted' => ThreadCompactedEvent.fromParams(params), + + // Turn lifecycle + 'turn/started' => TurnStartedEvent.fromParams(params), + 'turn/completed' => TurnCompletedEvent.fromParams(params), + + // Item lifecycle + 'item/started' => ItemStartedEvent.fromParams(params), + 'item/completed' => ItemCompletedEvent.fromParams(params), + + // Streaming deltas + 'item/agentMessage/delta' => AgentMessageDeltaEvent.fromParams(params), + 'item/reasoning/summaryTextDelta' => + ReasoningSummaryDeltaEvent.fromParams(params), + 'item/reasoning/textDelta' => + ReasoningTextDeltaEvent.fromParams(params), + 'item/commandExecution/outputDelta' => + CommandOutputDeltaEvent.fromParams(params), + 'item/fileChange/outputDelta' => + FileChangeOutputDeltaEvent.fromParams(params), + 'item/mcpToolCall/progress' => + McpToolCallProgressEvent.fromParams(params), + + // Legacy codex/event namespace (still emitted alongside new events) + 'codex/event/task_complete' => TaskCompleteEvent.fromParams(params), + 'codex/event/mcp_startup_complete' => const McpStartupCompleteEvent(), + + // Errors + 'error' => CodexErrorEvent.fromParams(params), + + // Everything else + _ => UnknownCodexEvent(method: method, params: params), }; } } +// --------------------------------------------------------------------------- +// Thread lifecycle +// --------------------------------------------------------------------------- + class ThreadStartedEvent extends CodexEvent { final String threadId; + final Map threadData; + + const ThreadStartedEvent({ + required this.threadId, + required this.threadData, + }); + + factory ThreadStartedEvent.fromParams(Map params) { + final thread = params['thread'] as Map? ?? {}; + return ThreadStartedEvent( + threadId: thread['id'] as String? ?? '', + threadData: thread, + ); + } +} + +class ThreadNameUpdatedEvent extends CodexEvent { + final String threadId; + final String name; - const ThreadStartedEvent({required this.threadId}); + const ThreadNameUpdatedEvent({ + required this.threadId, + required this.name, + }); - factory ThreadStartedEvent.fromJson(Map json) { - return ThreadStartedEvent(threadId: json['thread_id'] as String? ?? ''); + factory ThreadNameUpdatedEvent.fromParams(Map params) { + return ThreadNameUpdatedEvent( + threadId: params['threadId'] as String? ?? '', + name: params['name'] as String? ?? '', + ); } } +class ThreadCompactedEvent extends CodexEvent { + final Map params; + + const ThreadCompactedEvent({required this.params}); + + factory ThreadCompactedEvent.fromParams(Map params) { + return ThreadCompactedEvent(params: params); + } +} + +// --------------------------------------------------------------------------- +// Turn lifecycle +// --------------------------------------------------------------------------- + class TurnStartedEvent extends CodexEvent { - const TurnStartedEvent(); + final String turnId; + final Map turnData; + + const TurnStartedEvent({ + required this.turnId, + required this.turnData, + }); + + factory TurnStartedEvent.fromParams(Map params) { + final turn = params['turn'] as Map? ?? {}; + return TurnStartedEvent( + turnId: turn['id'] as String? ?? '', + turnData: turn, + ); + } } class TurnCompletedEvent extends CodexEvent { - final CodexUsage? usage; + final String turnId; + final String status; + final Map turnData; - const TurnCompletedEvent({this.usage}); + const TurnCompletedEvent({ + required this.turnId, + required this.status, + required this.turnData, + }); - factory TurnCompletedEvent.fromJson(Map json) { - final usageJson = json['usage'] as Map?; + factory TurnCompletedEvent.fromParams(Map params) { + final turn = params['turn'] as Map? ?? {}; return TurnCompletedEvent( - usage: usageJson != null ? CodexUsage.fromJson(usageJson) : null, + turnId: turn['id'] as String? ?? '', + status: turn['status'] as String? ?? '', + turnData: turn, ); } } -class TurnFailedEvent extends CodexEvent { - final String? error; - final Map? details; - - const TurnFailedEvent({this.error, this.details}); +// --------------------------------------------------------------------------- +// Item lifecycle +// --------------------------------------------------------------------------- - factory TurnFailedEvent.fromJson(Map json) { - final errorData = json['error']; - String? errorMessage; - Map? details; +class ItemStartedEvent extends CodexEvent { + final String itemId; + final String itemType; + final Map itemData; - if (errorData is String) { - errorMessage = errorData; - } else if (errorData is Map) { - errorMessage = errorData['message'] as String?; - details = errorData; - } + const ItemStartedEvent({ + required this.itemId, + required this.itemType, + required this.itemData, + }); - return TurnFailedEvent(error: errorMessage, details: details); + factory ItemStartedEvent.fromParams(Map params) { + final item = params['item'] as Map? ?? {}; + return ItemStartedEvent( + itemId: item['id'] as String? ?? '', + itemType: item['type'] as String? ?? '', + itemData: item, + ); } } -class ItemEvent extends CodexEvent { - /// One of 'item.started', 'item.updated', 'item.completed' - final String eventType; +class ItemCompletedEvent extends CodexEvent { final String itemId; - - /// One of 'agent_message', 'command_execution', 'file_change', - /// 'mcp_tool_call', 'reasoning', 'web_search', 'plan_update' final String itemType; - final String? status; - final Map data; + final Map itemData; - const ItemEvent({ - required this.eventType, + const ItemCompletedEvent({ required this.itemId, required this.itemType, - this.status, - required this.data, + required this.itemData, }); - factory ItemEvent.fromJson(String eventType, Map json) { - final item = json['item'] as Map? ?? {}; - return ItemEvent( - eventType: eventType, + factory ItemCompletedEvent.fromParams(Map params) { + final item = params['item'] as Map? ?? {}; + return ItemCompletedEvent( itemId: item['id'] as String? ?? '', itemType: item['type'] as String? ?? '', - status: item['status'] as String?, - data: item, + itemData: item, + ); + } +} + +// --------------------------------------------------------------------------- +// Streaming deltas +// --------------------------------------------------------------------------- + +class AgentMessageDeltaEvent extends CodexEvent { + final String itemId; + final String delta; + + const AgentMessageDeltaEvent({ + required this.itemId, + required this.delta, + }); + + factory AgentMessageDeltaEvent.fromParams(Map params) { + return AgentMessageDeltaEvent( + itemId: params['itemId'] as String? ?? '', + delta: params['delta'] as String? ?? '', + ); + } +} + +class ReasoningSummaryDeltaEvent extends CodexEvent { + final String itemId; + final String delta; + + const ReasoningSummaryDeltaEvent({ + required this.itemId, + required this.delta, + }); + + factory ReasoningSummaryDeltaEvent.fromParams(Map params) { + return ReasoningSummaryDeltaEvent( + itemId: params['itemId'] as String? ?? '', + delta: params['delta'] as String? ?? '', + ); + } +} + +class ReasoningTextDeltaEvent extends CodexEvent { + final String itemId; + final String delta; + + const ReasoningTextDeltaEvent({ + required this.itemId, + required this.delta, + }); + + factory ReasoningTextDeltaEvent.fromParams(Map params) { + return ReasoningTextDeltaEvent( + itemId: params['itemId'] as String? ?? '', + delta: params['delta'] as String? ?? '', + ); + } +} + +class CommandOutputDeltaEvent extends CodexEvent { + final String itemId; + final String delta; + + const CommandOutputDeltaEvent({ + required this.itemId, + required this.delta, + }); + + factory CommandOutputDeltaEvent.fromParams(Map params) { + return CommandOutputDeltaEvent( + itemId: params['itemId'] as String? ?? '', + delta: params['delta'] as String? ?? '', ); } +} + +class FileChangeOutputDeltaEvent extends CodexEvent { + final String itemId; + final String delta; - bool get isStarted => eventType == 'item.started'; - bool get isUpdated => eventType == 'item.updated'; - bool get isCompleted => eventType == 'item.completed'; + const FileChangeOutputDeltaEvent({ + required this.itemId, + required this.delta, + }); + + factory FileChangeOutputDeltaEvent.fromParams(Map params) { + return FileChangeOutputDeltaEvent( + itemId: params['itemId'] as String? ?? '', + delta: params['delta'] as String? ?? '', + ); + } } +class McpToolCallProgressEvent extends CodexEvent { + final String itemId; + final Map params; + + const McpToolCallProgressEvent({ + required this.itemId, + required this.params, + }); + + factory McpToolCallProgressEvent.fromParams(Map params) { + return McpToolCallProgressEvent( + itemId: params['itemId'] as String? ?? '', + params: params, + ); + } +} + +// --------------------------------------------------------------------------- +// Token usage +// --------------------------------------------------------------------------- + +class TokenUsageUpdatedEvent extends CodexEvent { + final CodexUsage usage; + + const TokenUsageUpdatedEvent({required this.usage}); + + factory TokenUsageUpdatedEvent.fromParams(Map params) { + return TokenUsageUpdatedEvent( + usage: CodexUsage.fromJson(params), + ); + } +} + +// --------------------------------------------------------------------------- +// Legacy codex/event namespace +// --------------------------------------------------------------------------- + +class TaskCompleteEvent extends CodexEvent { + final String? lastAgentMessage; + final Map params; + + const TaskCompleteEvent({ + this.lastAgentMessage, + required this.params, + }); + + factory TaskCompleteEvent.fromParams(Map params) { + final msg = params['msg'] as Map? ?? {}; + return TaskCompleteEvent( + lastAgentMessage: msg['last_agent_message'] as String?, + params: params, + ); + } +} + +class McpStartupCompleteEvent extends CodexEvent { + const McpStartupCompleteEvent(); +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + class CodexErrorEvent extends CodexEvent { final String message; final Map? details; const CodexErrorEvent({required this.message, this.details}); - factory CodexErrorEvent.fromJson(Map json) { - final error = json['error']; - if (error is String) { - return CodexErrorEvent(message: error); - } else if (error is Map) { - return CodexErrorEvent( - message: error['message'] as String? ?? 'Unknown error', - details: error, - ); - } - return CodexErrorEvent( - message: json['message'] as String? ?? 'Unknown error', - details: json, - ); + factory CodexErrorEvent.fromParams(Map params) { + final message = params['message'] as String? ?? 'Unknown error'; + return CodexErrorEvent(message: message, details: params); } } +// --------------------------------------------------------------------------- +// Unknown / catch-all +// --------------------------------------------------------------------------- + class UnknownCodexEvent extends CodexEvent { - final Map rawData; - const UnknownCodexEvent(this.rawData); + final String method; + final Map params; + + const UnknownCodexEvent({required this.method, required this.params}); } +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + class CodexUsage { final int inputTokens; final int cachedInputTokens; @@ -144,10 +384,18 @@ class CodexUsage { }); factory CodexUsage.fromJson(Map json) { + // Support both the thread/tokenUsage/updated format and legacy format + final usage = json['usage'] as Map? ?? json; return CodexUsage( - inputTokens: json['input_tokens'] as int? ?? 0, - cachedInputTokens: json['cached_input_tokens'] as int? ?? 0, - outputTokens: json['output_tokens'] as int? ?? 0, + inputTokens: usage['input_tokens'] as int? ?? + usage['inputTokens'] as int? ?? + 0, + cachedInputTokens: usage['cached_input_tokens'] as int? ?? + usage['cachedInputTokens'] as int? ?? + 0, + outputTokens: usage['output_tokens'] as int? ?? + usage['outputTokens'] as int? ?? + 0, ); } } diff --git a/packages/codex_sdk/lib/src/protocol/codex_event_mapper.dart b/packages/codex_sdk/lib/src/protocol/codex_event_mapper.dart index 3a04610..0dff382 100644 --- a/packages/codex_sdk/lib/src/protocol/codex_event_mapper.dart +++ b/packages/codex_sdk/lib/src/protocol/codex_event_mapper.dart @@ -15,16 +15,46 @@ class CodexEventMapper { /// Map a single Codex event to zero or more ClaudeResponse objects. List mapEvent(CodexEvent event) { return switch (event) { + // Thread lifecycle ThreadStartedEvent e => _mapThreadStarted(e), + ThreadNameUpdatedEvent _ => [], + ThreadCompactedEvent _ => [], + + // Turn lifecycle TurnStartedEvent _ => _mapTurnStarted(), - TurnCompletedEvent e => _mapTurnCompleted(e), - TurnFailedEvent e => _mapTurnFailed(e), - ItemEvent e => _mapItem(e), + TurnCompletedEvent _ => _mapTurnCompleted(), + + // Item lifecycle + ItemStartedEvent e => _mapItemStarted(e), + ItemCompletedEvent e => _mapItemCompleted(e), + + // Streaming deltas + AgentMessageDeltaEvent e => _mapAgentMessageDelta(e), + ReasoningSummaryDeltaEvent _ => [], + ReasoningTextDeltaEvent _ => [], + CommandOutputDeltaEvent _ => [], + FileChangeOutputDeltaEvent _ => [], + McpToolCallProgressEvent _ => [], + + // Token usage + TokenUsageUpdatedEvent e => _mapTokenUsage(e), + + // Legacy events + TaskCompleteEvent e => _mapTaskComplete(e), + McpStartupCompleteEvent _ => [], + + // Errors CodexErrorEvent e => _mapError(e), + + // Unknown UnknownCodexEvent _ => [], }; } + // -------------------------------------------------------------------------- + // Thread lifecycle + // -------------------------------------------------------------------------- + List _mapThreadStarted(ThreadStartedEvent event) { return [ MetaResponse( @@ -35,6 +65,10 @@ class CodexEventMapper { ]; } + // -------------------------------------------------------------------------- + // Turn lifecycle + // -------------------------------------------------------------------------- + List _mapTurnStarted() { return [ StatusResponse( @@ -45,30 +79,96 @@ class CodexEventMapper { ]; } - List _mapTurnCompleted(TurnCompletedEvent event) { + List _mapTurnCompleted() { return [ CompletionResponse( id: _nextId(), timestamp: DateTime.now(), stopReason: 'completed', - inputTokens: event.usage?.inputTokens, - outputTokens: event.usage?.outputTokens, - cacheReadInputTokens: event.usage?.cachedInputTokens, ), ]; } - List _mapTurnFailed(TurnFailedEvent event) { + // -------------------------------------------------------------------------- + // Item lifecycle + // -------------------------------------------------------------------------- + + List _mapItemStarted(ItemStartedEvent event) { + return switch (event.itemType) { + 'agentMessage' => [], + 'commandExecution' => _mapCommandExecutionStarted(event), + 'fileChange' => _mapFileChangeStarted(event), + 'mcpToolCall' => _mapMcpToolCallStarted(event), + 'reasoning' => [], + 'webSearch' => _mapWebSearchStarted(event), + 'todoList' => [], + _ => [], + }; + } + + List _mapItemCompleted(ItemCompletedEvent event) { + return switch (event.itemType) { + 'agentMessage' => _mapAgentMessageCompleted(event), + 'commandExecution' => _mapCommandExecutionCompleted(event), + 'fileChange' => _mapFileChangeCompleted(event), + 'mcpToolCall' => _mapMcpToolCallCompleted(event), + 'reasoning' => _mapReasoningCompleted(event), + 'webSearch' => _mapWebSearchCompleted(event), + 'todoList' => _mapTodoListCompleted(event), + _ => [], + }; + } + + // -------------------------------------------------------------------------- + // Streaming deltas + // -------------------------------------------------------------------------- + + List _mapAgentMessageDelta(AgentMessageDeltaEvent event) { return [ - ErrorResponse( + TextResponse( + id: event.itemId, + timestamp: DateTime.now(), + content: event.delta, + isCumulative: false, + ), + ]; + } + + // -------------------------------------------------------------------------- + // Token usage + // -------------------------------------------------------------------------- + + List _mapTokenUsage(TokenUsageUpdatedEvent event) { + return [ + CompletionResponse( id: _nextId(), timestamp: DateTime.now(), - error: event.error ?? 'Turn failed', - details: event.details?.toString(), + stopReason: 'usage_update', + inputTokens: event.usage.inputTokens, + outputTokens: event.usage.outputTokens, + cacheReadInputTokens: event.usage.cachedInputTokens, ), ]; } + // -------------------------------------------------------------------------- + // Legacy events + // -------------------------------------------------------------------------- + + List _mapTaskComplete(TaskCompleteEvent event) { + return [ + CompletionResponse( + id: _nextId(), + timestamp: DateTime.now(), + stopReason: 'completed', + ), + ]; + } + + // -------------------------------------------------------------------------- + // Errors + // -------------------------------------------------------------------------- + List _mapError(CodexErrorEvent event) { return [ ErrorResponse( @@ -80,22 +180,12 @@ class CodexEventMapper { ]; } - List _mapItem(ItemEvent event) { - return switch (event.itemType) { - 'agent_message' => _mapAgentMessage(event), - 'command_execution' => _mapCommandExecution(event), - 'file_change' => _mapFileChange(event), - 'mcp_tool_call' => _mapMcpToolCall(event), - 'reasoning' => _mapReasoning(event), - 'web_search' => _mapWebSearch(event), - 'todo_list' => _mapTodoList(event), - _ => [], - }; - } + // -------------------------------------------------------------------------- + // Item type handlers + // -------------------------------------------------------------------------- - List _mapAgentMessage(ItemEvent event) { - if (!event.isCompleted) return []; - final text = event.data['text'] as String? ?? ''; + List _mapAgentMessageCompleted(ItemCompletedEvent event) { + final text = _extractStringField(event.itemData, 'text') ?? ''; if (text.isEmpty) return []; return [ TextResponse( @@ -107,127 +197,122 @@ class CodexEventMapper { ]; } - List _mapCommandExecution(ItemEvent event) { - if (event.isStarted) { - return [ - ToolUseResponse( - id: event.itemId, - timestamp: DateTime.now(), - toolName: 'Bash', - parameters: {'command': event.data['command'] as String? ?? ''}, - toolUseId: event.itemId, - ), - ]; - } - if (event.isCompleted) { - final exitCode = event.data['exit_code'] as int?; - final output = event.data['aggregated_output'] as String? ?? ''; - return [ - ToolResultResponse( - id: '${event.itemId}_result', - timestamp: DateTime.now(), - toolUseId: event.itemId, - content: output, - isError: exitCode != null && exitCode != 0, - ), - ]; - } - return []; + List _mapCommandExecutionStarted(ItemStartedEvent event) { + return [ + ToolUseResponse( + id: event.itemId, + timestamp: DateTime.now(), + toolName: 'Bash', + parameters: {'command': event.itemData['command'] as String? ?? ''}, + toolUseId: event.itemId, + ), + ]; } - List _mapFileChange(ItemEvent event) { - // Codex emits file changes with a `changes` array: - // { "changes": [{ "path": "lib/foo.dart", "kind": "add" }, ...] } - // where kind is "add", "update", or "delete". - final changes = event.data['changes'] as List?; - - if (event.isStarted) { - final params = {}; - if (changes != null && changes.isNotEmpty) { - final paths = changes - .map((c) => (c as Map)['path'] as String? ?? '') - .toList(); - params['files'] = paths; - final kind = (changes.first as Map)['kind'] as String? ?? 'update'; - params['kind'] = kind; - } - final toolName = _inferFileToolName(changes); - return [ - ToolUseResponse( - id: event.itemId, - timestamp: DateTime.now(), - toolName: toolName, - parameters: params, - toolUseId: event.itemId, - ), - ]; - } - if (event.isCompleted) { - final summary = changes != null - ? changes - .map((c) { - final path = (c as Map)['path'] ?? ''; - final kind = c['kind'] ?? ''; - return '$kind: $path'; - }) - .join('\n') - : 'Done'; - return [ - ToolResultResponse( - id: '${event.itemId}_result', - timestamp: DateTime.now(), - toolUseId: event.itemId, - content: summary, - isError: false, - ), - ]; - } - return []; + List _mapCommandExecutionCompleted( + ItemCompletedEvent event, + ) { + final exitCode = event.itemData['exit_code'] as int?; + final output = + event.itemData['aggregated_output'] as String? ?? + event.itemData['output'] as String? ?? + ''; + return [ + ToolResultResponse( + id: '${event.itemId}_result', + timestamp: DateTime.now(), + toolUseId: event.itemId, + content: output, + isError: exitCode != null && exitCode != 0, + ), + ]; } - List _mapMcpToolCall(ItemEvent event) { - if (event.isStarted) { - final serverLabel = event.data['server'] as String? ?? ''; - final toolName = event.data['tool'] as String? ?? ''; - final fullName = serverLabel.isNotEmpty - ? 'mcp__${serverLabel}__$toolName' - : toolName; - final arguments = event.data['arguments']; - return [ - ToolUseResponse( - id: event.itemId, - timestamp: DateTime.now(), - toolName: fullName, - parameters: arguments is Map - ? arguments - : {}, - toolUseId: event.itemId, - ), - ]; + List _mapFileChangeStarted(ItemStartedEvent event) { + final changes = event.itemData['changes'] as List?; + final params = {}; + if (changes != null && changes.isNotEmpty) { + final paths = + changes.map((c) => (c as Map)['path'] as String? ?? '').toList(); + params['files'] = paths; + final kind = (changes.first as Map)['kind'] as String? ?? 'update'; + params['kind'] = kind; } - if (event.isCompleted) { - final error = event.data['error']; - final isError = error != null; - final content = isError - ? _extractErrorMessage(error) - : _extractMcpResult(event.data['result']); - return [ - ToolResultResponse( - id: '${event.itemId}_result', - timestamp: DateTime.now(), - toolUseId: event.itemId, - content: content, - isError: isError, - ), - ]; - } - return []; + final toolName = _inferFileToolName(changes); + return [ + ToolUseResponse( + id: event.itemId, + timestamp: DateTime.now(), + toolName: toolName, + parameters: params, + toolUseId: event.itemId, + ), + ]; } - List _mapReasoning(ItemEvent event) { - if (!event.isCompleted) return []; - final text = - event.data['text'] as String? ?? event.data['summary'] as String? ?? ''; + List _mapFileChangeCompleted(ItemCompletedEvent event) { + final changes = event.itemData['changes'] as List?; + final summary = changes != null + ? changes + .map((c) { + final path = (c as Map)['path'] ?? ''; + final kind = c['kind'] ?? ''; + return '$kind: $path'; + }) + .join('\n') + : 'Done'; + return [ + ToolResultResponse( + id: '${event.itemId}_result', + timestamp: DateTime.now(), + toolUseId: event.itemId, + content: summary, + isError: false, + ), + ]; + } + + List _mapMcpToolCallStarted(ItemStartedEvent event) { + final serverLabel = event.itemData['server'] as String? ?? ''; + final toolName = event.itemData['tool'] as String? ?? ''; + final fullName = + serverLabel.isNotEmpty ? 'mcp__${serverLabel}__$toolName' : toolName; + final arguments = event.itemData['arguments']; + return [ + ToolUseResponse( + id: event.itemId, + timestamp: DateTime.now(), + toolName: fullName, + parameters: + arguments is Map + ? arguments + : {}, + toolUseId: event.itemId, + ), + ]; + } + + List _mapMcpToolCallCompleted(ItemCompletedEvent event) { + final error = event.itemData['error']; + final isError = error != null; + final content = isError + ? _extractErrorMessage(error) + : _extractMcpResult(event.itemData['result']); + return [ + ToolResultResponse( + id: '${event.itemId}_result', + timestamp: DateTime.now(), + toolUseId: event.itemId, + content: content, + isError: isError, + ), + ]; + } + + List _mapReasoningCompleted(ItemCompletedEvent event) { + final text = _extractStringField(event.itemData, 'text') ?? + _extractStringField(event.itemData, 'summary') ?? + ''; if (text.isEmpty) return []; return [ TextResponse( @@ -239,36 +324,33 @@ class CodexEventMapper { ]; } - List _mapWebSearch(ItemEvent event) { - if (event.isStarted) { - final query = event.data['query'] as String? ?? ''; - return [ - ToolUseResponse( - id: event.itemId, - timestamp: DateTime.now(), - toolName: 'WebSearch', - parameters: {'query': query}, - toolUseId: event.itemId, - ), - ]; - } - if (event.isCompleted) { - return [ - ToolResultResponse( - id: '${event.itemId}_result', - timestamp: DateTime.now(), - toolUseId: event.itemId, - content: 'Search complete', - isError: false, - ), - ]; - } - return []; + List _mapWebSearchStarted(ItemStartedEvent event) { + final query = event.itemData['query'] as String? ?? ''; + return [ + ToolUseResponse( + id: event.itemId, + timestamp: DateTime.now(), + toolName: 'WebSearch', + parameters: {'query': query}, + toolUseId: event.itemId, + ), + ]; + } + + List _mapWebSearchCompleted(ItemCompletedEvent event) { + return [ + ToolResultResponse( + id: '${event.itemId}_result', + timestamp: DateTime.now(), + toolUseId: event.itemId, + content: 'Search complete', + isError: false, + ), + ]; } - List _mapTodoList(ItemEvent event) { - if (!event.isCompleted && !event.isUpdated) return []; - final items = event.data['items'] as List? ?? []; + List _mapTodoListCompleted(ItemCompletedEvent event) { + final items = event.itemData['items'] as List? ?? []; if (items.isEmpty) return []; final text = items .map((item) { @@ -288,6 +370,10 @@ class CodexEventMapper { ]; } + // -------------------------------------------------------------------------- + // Helpers + // -------------------------------------------------------------------------- + String _inferFileToolName(List? changes) { if (changes == null || changes.isEmpty) return 'Write'; final kind = (changes.first as Map)['kind'] as String? ?? ''; @@ -299,13 +385,10 @@ class CodexEventMapper { }; } - /// Extract text from MCP result, which can be a string, a content block - /// array, or a plain map. String _extractMcpResult(dynamic result) { if (result == null) return ''; if (result is String) return result; if (result is List) { - // Content block array: [{type: "text", text: "..."}, ...] return result .map((block) { if (block is Map && block['type'] == 'text') { @@ -320,10 +403,30 @@ class CodexEventMapper { return result.toString(); } - /// Extract error message from structured or string error. String _extractErrorMessage(dynamic error) { if (error is String) return error; if (error is Map) return error['message'] as String? ?? error.toString(); return error.toString(); } + + /// Extracts a string from a field that may be a String or a List of content + /// blocks (e.g., `[{"type": "summary_text", "text": "..."}]`). + String? _extractStringField(Map data, String key) { + final value = data[key]; + if (value is String) return value; + if (value is List) { + final parts = value + .map((block) { + if (block is Map && block.containsKey('text')) { + return block['text'] as String? ?? ''; + } + if (block is String) return block; + return ''; + }) + .where((s) => s.isNotEmpty) + .toList(); + return parts.isEmpty ? null : parts.join('\n'); + } + return null; + } } diff --git a/packages/codex_sdk/lib/src/protocol/codex_event_parser.dart b/packages/codex_sdk/lib/src/protocol/codex_event_parser.dart index 6bf9562..38c526c 100644 --- a/packages/codex_sdk/lib/src/protocol/codex_event_parser.dart +++ b/packages/codex_sdk/lib/src/protocol/codex_event_parser.dart @@ -1,32 +1,13 @@ -import 'dart:convert'; - import 'codex_event.dart'; +import 'json_rpc_message.dart'; -/// Parses raw JSONL lines from `codex exec --json` stdout into [CodexEvent]s. +/// Converts [JsonRpcNotification]s from the transport into [CodexEvent]s. +/// +/// The transport layer handles JSONL framing and message routing. +/// This parser just maps typed notification objects to domain events. class CodexEventParser { - /// Parse a single JSON line into a [CodexEvent]. - /// Returns null if the line is empty or cannot be parsed. - CodexEvent? parseLine(String line) { - final trimmed = line.trim(); - if (trimmed.isEmpty) return null; - - try { - final json = jsonDecode(trimmed) as Map; - return CodexEvent.fromJson(json); - } catch (_) { - return null; - } - } - - /// Parse a chunk of text that may contain multiple JSONL lines. - List parseChunk(String chunk) { - final events = []; - for (final line in chunk.split('\n')) { - final event = parseLine(line); - if (event != null) { - events.add(event); - } - } - return events; + /// Convert a [JsonRpcNotification] into a [CodexEvent]. + CodexEvent parseNotification(JsonRpcNotification notification) { + return CodexEvent.fromNotification(notification); } } diff --git a/packages/codex_sdk/lib/src/protocol/json_rpc_message.dart b/packages/codex_sdk/lib/src/protocol/json_rpc_message.dart new file mode 100644 index 0000000..2698d59 --- /dev/null +++ b/packages/codex_sdk/lib/src/protocol/json_rpc_message.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; + +/// JSON-RPC message types for the Codex app-server protocol. +/// +/// The app-server communicates via JSONL (one JSON object per line) over +/// stdin/stdout. Messages follow JSON-RPC 2.0 conventions but omit the +/// `"jsonrpc":"2.0"` header. +/// +/// Three message patterns: +/// - **Notifications** (server→client): no `id`, just `method` + `params` +/// - **Requests** (bidirectional): has `id` + `method` + `params`, expects a response +/// - **Responses**: has `id` + `result`/`error`, correlates to a prior request +sealed class JsonRpcMessage { + const JsonRpcMessage(); + + factory JsonRpcMessage.fromJson(Map json) { + final hasId = json.containsKey('id'); + final hasMethod = json.containsKey('method'); + final hasResult = json.containsKey('result'); + final hasError = json.containsKey('error'); + + if (hasId && (hasResult || hasError) && !hasMethod) { + // Response to a request we sent + return JsonRpcResponse.fromJson(json); + } + + if (hasId && hasMethod) { + // Request from server (e.g., approval requests) + return JsonRpcRequest.fromJson(json); + } + + if (hasMethod && !hasId) { + // Notification from server + return JsonRpcNotification.fromJson(json); + } + + // Error response has id + error but no method + if (hasId && hasError) { + return JsonRpcResponse.fromJson(json); + } + + return JsonRpcNotification( + method: json['method'] as String? ?? 'unknown', + params: json, + ); + } + + /// Parse a single JSONL line into a [JsonRpcMessage]. + /// Returns null if the line is empty or cannot be parsed. + static JsonRpcMessage? parseLine(String line) { + final trimmed = line.trim(); + if (trimmed.isEmpty) return null; + + try { + final json = jsonDecode(trimmed) as Map; + return JsonRpcMessage.fromJson(json); + } catch (_) { + return null; + } + } +} + +/// A notification from the server (no response expected). +/// +/// Examples: `turn/started`, `item/completed`, `item/agentMessage/delta` +class JsonRpcNotification extends JsonRpcMessage { + final String method; + final Map params; + + const JsonRpcNotification({ + required this.method, + required this.params, + }); + + factory JsonRpcNotification.fromJson(Map json) { + return JsonRpcNotification( + method: json['method'] as String? ?? '', + params: json['params'] as Map? ?? {}, + ); + } + + @override + String toString() => 'JsonRpcNotification(method: $method)'; +} + +/// A request from the server that requires a response from the client. +/// +/// Used for approval requests: `item/commandExecution/requestApproval`, +/// `item/fileChange/requestApproval`, `item/tool/requestUserInput`. +class JsonRpcRequest extends JsonRpcMessage { + /// Request ID (String or int). Must be echoed back in the response. + final dynamic id; + final String method; + final Map params; + + const JsonRpcRequest({ + required this.id, + required this.method, + required this.params, + }); + + factory JsonRpcRequest.fromJson(Map json) { + return JsonRpcRequest( + id: json['id'], + method: json['method'] as String? ?? '', + params: json['params'] as Map? ?? {}, + ); + } + + @override + String toString() => 'JsonRpcRequest(id: $id, method: $method)'; +} + +/// A response to a request we sent to the server. +class JsonRpcResponse extends JsonRpcMessage { + /// Matches the `id` of the request this responds to. + final dynamic id; + final Map? result; + final JsonRpcError? error; + + const JsonRpcResponse({ + required this.id, + this.result, + this.error, + }); + + bool get isError => error != null; + + factory JsonRpcResponse.fromJson(Map json) { + final errorData = json['error']; + JsonRpcError? error; + if (errorData is Map) { + error = JsonRpcError.fromJson(errorData); + } + + final resultData = json['result']; + final Map? result = resultData is Map + ? Map.from(resultData) + : null; + + return JsonRpcResponse( + id: json['id'], + result: result, + error: error, + ); + } + + @override + String toString() => 'JsonRpcResponse(id: $id, isError: $isError)'; +} + +/// A JSON-RPC error object. +class JsonRpcError { + final int code; + final String message; + final dynamic data; + + const JsonRpcError({ + required this.code, + required this.message, + this.data, + }); + + factory JsonRpcError.fromJson(Map json) { + return JsonRpcError( + code: json['code'] as int? ?? -1, + message: json['message'] as String? ?? 'Unknown error', + data: json['data'], + ); + } + + @override + String toString() => 'JsonRpcError(code: $code, message: $message)'; +} diff --git a/packages/codex_sdk/lib/src/transport/codex_transport.dart b/packages/codex_sdk/lib/src/transport/codex_transport.dart new file mode 100644 index 0000000..e3f63fb --- /dev/null +++ b/packages/codex_sdk/lib/src/transport/codex_transport.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../protocol/json_rpc_message.dart'; + +/// Persistent subprocess transport for the Codex app-server. +/// +/// Manages a long-lived `codex app-server` process, communicating via +/// JSON-RPC over JSONL on stdin/stdout. Handles: +/// - Request/response correlation (auto-increments `id`, matches responses) +/// - Routing incoming messages to typed streams +/// - Subprocess lifecycle (start/close) +class CodexTransport { + Process? _process; + int _nextId = 0; + bool _closed = false; + + /// Pending requests waiting for a response, keyed by request id. + final _pendingRequests = >{}; + + /// Server notifications (no id, no response expected). + final _notificationController = + StreamController.broadcast(); + + /// Server requests that need a client response (e.g., approvals). + final _serverRequestController = + StreamController.broadcast(); + + /// Raw stderr output for error diagnostics. + final _stderrBuffer = StringBuffer(); + + /// Whether the transport is currently connected to a subprocess. + bool get isRunning => _process != null && !_closed; + + /// Server notifications stream. + Stream get notifications => + _notificationController.stream; + + /// Server requests stream (approval requests). + Stream get serverRequests => + _serverRequestController.stream; + + /// Start the `codex app-server` subprocess. + Future start({ + String? workingDirectory, + List extraArgs = const [], + }) async { + if (_closed) { + throw StateError('Transport has been closed'); + } + if (_process != null) { + throw StateError('Transport already started'); + } + + final args = ['app-server', ...extraArgs]; + + _process = await Process.start( + 'codex', + args, + workingDirectory: workingDirectory, + environment: Platform.environment, + ); + + _process!.stdout.transform(utf8.decoder).listen( + _onStdoutData, + onDone: _onProcessDone, + ); + + _process!.stderr.transform(utf8.decoder).listen((chunk) { + _stderrBuffer.write(chunk); + }); + } + + /// Send a JSON-RPC request and wait for the correlated response. + Future sendRequest( + String method, [ + Map? params, + ]) { + _ensureRunning(); + + final id = _nextId++; + final completer = Completer(); + _pendingRequests[id] = completer; + + final request = { + 'method': method, + 'id': id, + if (params != null) 'params': params, + }; + + _writeLine(jsonEncode(request)); + return completer.future; + } + + /// Send a JSON-RPC notification (fire-and-forget, no response expected). + void sendNotification(String method, [Map? params]) { + _ensureRunning(); + + final notification = { + 'method': method, + if (params != null) 'params': params, + }; + + _writeLine(jsonEncode(notification)); + } + + /// Respond to a server-initiated request (e.g., approval decisions). + void respondToRequest(dynamic requestId, Map result) { + _ensureRunning(); + + final response = { + 'id': requestId, + 'result': result, + }; + + _writeLine(jsonEncode(response)); + } + + /// Close the transport and kill the subprocess. + Future close() async { + if (_closed) return; + _closed = true; + + // Complete all pending requests with an error + for (final completer in _pendingRequests.values) { + if (!completer.isCompleted) { + completer.completeError( + StateError('Transport closed while request was pending'), + ); + } + } + _pendingRequests.clear(); + + _process?.kill(ProcessSignal.sigterm); + _process = null; + + await _notificationController.close(); + await _serverRequestController.close(); + } + + // -------------------------------------------------------------------------- + // Private + // -------------------------------------------------------------------------- + + final _lineBuffer = StringBuffer(); + + void _onStdoutData(String chunk) { + _lineBuffer.write(chunk); + final content = _lineBuffer.toString(); + final lines = content.split('\n'); + + // Keep the last potentially incomplete line + _lineBuffer.clear(); + if (!content.endsWith('\n') && lines.isNotEmpty) { + _lineBuffer.write(lines.removeLast()); + } else if (lines.isNotEmpty && lines.last.isEmpty) { + lines.removeLast(); + } + + for (final line in lines) { + final trimmed = line.trim(); + if (trimmed.isEmpty) continue; + _routeMessage(trimmed); + } + } + + void _routeMessage(String line) { + final message = JsonRpcMessage.parseLine(line); + if (message == null) return; + + switch (message) { + case JsonRpcResponse response: + final completer = _pendingRequests.remove(response.id); + if (completer != null && !completer.isCompleted) { + completer.complete(response); + } + case JsonRpcRequest request: + if (!_serverRequestController.isClosed) { + _serverRequestController.add(request); + } + case JsonRpcNotification notification: + if (!_notificationController.isClosed) { + _notificationController.add(notification); + } + } + } + + void _onProcessDone() { + if (_closed) return; + + // Complete all pending requests with an error + for (final completer in _pendingRequests.values) { + if (!completer.isCompleted) { + final stderr = _stderrBuffer.toString(); + completer.completeError( + StateError( + 'codex app-server process terminated unexpectedly' + '${stderr.isNotEmpty ? ': $stderr' : ''}', + ), + ); + } + } + _pendingRequests.clear(); + _process = null; + } + + void _writeLine(String json) { + _process!.stdin.writeln(json); + } + + void _ensureRunning() { + if (_closed) throw StateError('Transport has been closed'); + if (_process == null) throw StateError('Transport not started'); + } +} diff --git a/packages/codex_sdk/test/codex_client_e2e_test.dart b/packages/codex_sdk/test/codex_client_e2e_test.dart index a23b668..3dcaee5 100644 --- a/packages/codex_sdk/test/codex_client_e2e_test.dart +++ b/packages/codex_sdk/test/codex_client_e2e_test.dart @@ -5,7 +5,7 @@ import 'package:claude_sdk/claude_sdk.dart'; import 'package:codex_sdk/codex_sdk.dart'; import 'package:test/test.dart'; -/// End-to-end test that runs a real `codex exec` process. +/// End-to-end test that runs a real `codex app-server` subprocess. /// /// Requires: /// - `codex` CLI installed and on PATH @@ -29,6 +29,7 @@ void main() { codexConfig: CodexConfig( workingDirectory: tempDir.path, skipGitRepoCheck: true, + approvalPolicy: 'never', ), ); }); @@ -106,23 +107,16 @@ void main() { expect(conv.isProcessing, isFalse); }); - test('captures thread ID from thread.started event', () async { + test('captures thread ID from thread/start response', () async { await client.init(); - final turnFuture = client.onTurnComplete.first; - client.sendMessage(Message(text: 'Say "ok"')); - - await turnFuture.timeout( - const Duration(seconds: 60), - onTimeout: () => fail('Timed out waiting for turn completion'), - ); - + // Thread ID is set during init from thread/start response expect( - client.initData, + client.threadId, isNotNull, - reason: 'Expected MetaResponse from thread.started', + reason: 'Expected threadId from thread/start', ); - expect(client.initData!.metadata['session_id'], isNotEmpty); + expect(client.threadId, isNotEmpty); }); test('status transitions correctly during a turn', () async { @@ -150,7 +144,7 @@ void main() { expect(statuses.last, ClaudeStatus.ready); }); - test('abort kills the process and resets status', () async { + test('abort sends turn/interrupt and resets status', () async { await client.init(); final processingFuture = client.statusStream @@ -177,7 +171,7 @@ void main() { // If it already finished, that's fine }); - test('clearConversation resets state', () async { + test('clearConversation resets state and starts new thread', () async { await client.init(); final turnFuture = client.onTurnComplete.first; @@ -189,12 +183,17 @@ void main() { ); expect(client.currentConversation.messages, isNotEmpty); + final oldThreadId = client.threadId; await client.clearConversation(); expect(client.currentConversation.messages, isEmpty); + + // Should have a new thread ID + expect(client.threadId, isNotNull); + expect(client.threadId, isNot(equals(oldThreadId))); }); - test('multi-turn resume sends follow-up on same thread', () async { + test('multi-turn sends follow-up on same thread', () async { await client.init(); // Turn 1: establish a fact @@ -208,12 +207,11 @@ void main() { onTimeout: () => fail('Timed out on turn 1'), ); - // Should have captured a thread ID - expect(client.initData, isNotNull); - final threadId = client.initData!.metadata['session_id'] as String; - expect(threadId, isNotEmpty); + // Should have captured a thread ID during init + expect(client.threadId, isNotNull); + final threadId = client.threadId; - // Turn 2: ask about the fact (resume on same thread) + // Turn 2: ask about the fact (same persistent thread) final turn2Future = client.onTurnComplete.first; client.sendMessage( Message(text: 'What number did I just tell you to remember? Reply with just the number.'), @@ -224,6 +222,9 @@ void main() { onTimeout: () => fail('Timed out on turn 2'), ); + // Thread ID should be the same (persistent session) + expect(client.threadId, equals(threadId)); + // Should have user + assistant messages from both turns final conv = client.currentConversation; final userMessages = conv.messages diff --git a/packages/codex_sdk/test/codex_config_test.dart b/packages/codex_sdk/test/codex_config_test.dart index 338fe25..de34cf4 100644 --- a/packages/codex_sdk/test/codex_config_test.dart +++ b/packages/codex_sdk/test/codex_config_test.dart @@ -2,115 +2,81 @@ import 'package:codex_sdk/codex_sdk.dart'; import 'package:test/test.dart'; void main() { - group('CodexConfig.toCliArgs', () { - test('generates minimal args with defaults', () { - const config = CodexConfig(); - final args = config.toCliArgs(); - expect(args, ['exec', '--json', '--full-auto']); + group('CodexConfig.toThreadStartParams', () { + test('includes cwd when workingDirectory is set', () { + const config = CodexConfig(workingDirectory: '/tmp/project'); + final params = config.toThreadStartParams(); + expect(params['cwd'], '/tmp/project'); }); - test('always includes --full-auto', () { + test('omits cwd when workingDirectory is null', () { const config = CodexConfig(); - final args = config.toCliArgs(); - expect(args, contains('--full-auto')); - }); - - test('includes model flag', () { - const config = CodexConfig(model: 'o3'); - final args = config.toCliArgs(); - expect(args, contains('--model')); - expect(args[args.indexOf('--model') + 1], 'o3'); - }); - - test('includes profile flag', () { - const config = CodexConfig(profile: 'my-profile'); - final args = config.toCliArgs(); - expect(args, contains('--profile')); - expect(args[args.indexOf('--profile') + 1], 'my-profile'); + final params = config.toThreadStartParams(); + expect(params.containsKey('cwd'), isFalse); }); - test('includes sandbox mode when not default', () { + test('includes sandbox mode', () { const config = CodexConfig(sandboxMode: 'danger-full-access'); - final args = config.toCliArgs(); - expect(args, contains('--sandbox')); - expect(args[args.indexOf('--sandbox') + 1], 'danger-full-access'); + final params = config.toThreadStartParams(); + expect(params['sandbox'], 'danger-full-access'); }); - test('omits sandbox mode when default workspace-write', () { - const config = CodexConfig(sandboxMode: 'workspace-write'); - final args = config.toCliArgs(); - expect(args, isNot(contains('--sandbox'))); - }); - - test('includes system prompt append', () { - const config = CodexConfig(appendSystemPrompt: 'Be concise'); - final args = config.toCliArgs(); - expect(args, contains('-c')); - final cIndex = args.indexOf('-c'); - expect(args[cIndex + 1], 'instructions.append=Be concise'); + test('uses default sandbox mode workspace-write', () { + const config = CodexConfig(); + final params = config.toThreadStartParams(); + expect(params['sandbox'], 'workspace-write'); }); - test('includes additional dirs with --add-dir', () { - const config = CodexConfig(additionalDirs: ['/tmp/a', '/tmp/b']); - final args = config.toCliArgs(); - expect(args.where((a) => a == '--add-dir').length, 2); - final firstIdx = args.indexOf('--add-dir'); - expect(args[firstIdx + 1], '/tmp/a'); - final secondIdx = args.indexOf('--add-dir', firstIdx + 1); - expect(args[secondIdx + 1], '/tmp/b'); + test('includes approval policy', () { + const config = CodexConfig(approvalPolicy: 'never'); + final params = config.toThreadStartParams(); + expect(params['approvalPolicy'], 'never'); }); - test('includes additional flags', () { - const config = CodexConfig(additionalFlags: ['--verbose', '--debug']); - final args = config.toCliArgs(); - expect(args, containsAll(['--verbose', '--debug'])); + test('uses default approval policy on-failure', () { + const config = CodexConfig(); + final params = config.toThreadStartParams(); + expect(params['approvalPolicy'], 'on-failure'); }); - test('includes --skip-git-repo-check when set', () { - const config = CodexConfig(skipGitRepoCheck: true); - final args = config.toCliArgs(); - expect(args, contains('--skip-git-repo-check')); + test('includes model when set', () { + const config = CodexConfig(model: 'o3'); + final params = config.toThreadStartParams(); + expect(params['model'], 'o3'); }); - test('omits --skip-git-repo-check by default', () { + test('omits model when null', () { const config = CodexConfig(); - final args = config.toCliArgs(); - expect(args, isNot(contains('--skip-git-repo-check'))); + final params = config.toThreadStartParams(); + expect(params.containsKey('model'), isFalse); }); - test('places resume subcommand after all flags', () { - const config = CodexConfig(model: 'o3', additionalFlags: ['--verbose']); - final args = config.toCliArgs( - isResume: true, - resumeThreadId: 'thread_123', - ); - - final resumeIndex = args.indexOf('resume'); - expect(resumeIndex, isNot(-1)); - expect(args[resumeIndex + 1], 'thread_123'); - - // All flags should come before resume - expect(args.indexOf('--model'), lessThan(resumeIndex)); - expect(args.indexOf('--verbose'), lessThan(resumeIndex)); - expect(args.indexOf('--json'), lessThan(resumeIndex)); - }); - - test('does not include resume when isResume is false', () { - const config = CodexConfig(); - final args = config.toCliArgs(isResume: false); - expect(args, isNot(contains('resume'))); + test('includes developerInstructions from appendSystemPrompt', () { + const config = CodexConfig(appendSystemPrompt: 'Be concise'); + final params = config.toThreadStartParams(); + expect(params['developerInstructions'], 'Be concise'); }); - test('does not include resume when threadId is null', () { + test('omits developerInstructions when appendSystemPrompt is null', () { const config = CodexConfig(); - final args = config.toCliArgs(isResume: true, resumeThreadId: null); - expect(args, isNot(contains('resume'))); + final params = config.toThreadStartParams(); + expect(params.containsKey('developerInstructions'), isFalse); }); - test('starts with exec', () { - const config = CodexConfig(); - final args = config.toCliArgs(); - expect(args.first, 'exec'); + test('builds complete params with all fields', () { + const config = CodexConfig( + model: 'o4-mini', + sandboxMode: 'read-only', + workingDirectory: '/home/user/project', + appendSystemPrompt: 'Be helpful', + approvalPolicy: 'on-request', + ); + final params = config.toThreadStartParams(); + expect(params['model'], 'o4-mini'); + expect(params['sandbox'], 'read-only'); + expect(params['cwd'], '/home/user/project'); + expect(params['developerInstructions'], 'Be helpful'); + expect(params['approvalPolicy'], 'on-request'); }); }); @@ -123,8 +89,8 @@ void main() { workingDirectory: '/tmp', sessionId: 'session_1', appendSystemPrompt: 'Be brief', - additionalFlags: ['--verbose'], additionalDirs: ['/data'], + approvalPolicy: 'never', ); final copy = original.copyWith(model: 'o4-mini'); @@ -134,8 +100,8 @@ void main() { expect(copy.workingDirectory, '/tmp'); expect(copy.sessionId, 'session_1'); expect(copy.appendSystemPrompt, 'Be brief'); - expect(copy.additionalFlags, ['--verbose']); expect(copy.additionalDirs, ['/data']); + expect(copy.approvalPolicy, 'never'); }); test('preserves values when no overrides given', () { @@ -143,5 +109,11 @@ void main() { final copy = original.copyWith(); expect(copy.model, 'o3'); }); + + test('can override approvalPolicy', () { + const original = CodexConfig(); + final copy = original.copyWith(approvalPolicy: 'never'); + expect(copy.approvalPolicy, 'never'); + }); }); } diff --git a/packages/codex_sdk/test/codex_event_mapper_test.dart b/packages/codex_sdk/test/codex_event_mapper_test.dart index 48aa2af..6f933d6 100644 --- a/packages/codex_sdk/test/codex_event_mapper_test.dart +++ b/packages/codex_sdk/test/codex_event_mapper_test.dart @@ -10,9 +10,12 @@ void main() { }); group('CodexEventMapper.mapEvent', () { - group('thread.started', () { + group('thread/started', () { test('maps to MetaResponse with session_id', () { - final event = ThreadStartedEvent(threadId: 'thread_abc'); + const event = ThreadStartedEvent( + threadId: 'thread_abc', + threadData: {}, + ); final responses = mapper.mapEvent(event); expect(responses, hasLength(1)); expect(responses[0], isA()); @@ -22,9 +25,9 @@ void main() { }); }); - group('turn.started', () { + group('turn/started', () { test('maps to StatusResponse processing', () { - const event = TurnStartedEvent(); + const event = TurnStartedEvent(turnId: '0', turnData: {}); final responses = mapper.mapEvent(event); expect(responses, hasLength(1)); expect(responses[0], isA()); @@ -35,54 +38,31 @@ void main() { }); }); - group('turn.completed', () { - test('maps to CompletionResponse with usage', () { - final event = TurnCompletedEvent( - usage: CodexUsage( - inputTokens: 100, - cachedInputTokens: 50, - outputTokens: 200, - ), + group('turn/completed', () { + test('maps to CompletionResponse', () { + const event = TurnCompletedEvent( + turnId: '0', + status: 'completed', + turnData: {}, ); final responses = mapper.mapEvent(event); expect(responses, hasLength(1)); expect(responses[0], isA()); final completion = responses[0] as CompletionResponse; expect(completion.stopReason, 'completed'); - expect(completion.inputTokens, 100); - expect(completion.outputTokens, 200); - expect(completion.cacheReadInputTokens, 50); - }); - - test('maps to CompletionResponse without usage', () { - const event = TurnCompletedEvent(); - final responses = mapper.mapEvent(event); - expect(responses, hasLength(1)); - final completion = responses[0] as CompletionResponse; - expect(completion.inputTokens, isNull); - expect(completion.outputTokens, isNull); }); }); - group('turn.failed', () { - test('maps to ErrorResponse', () { - const event = TurnFailedEvent( - error: 'rate limit exceeded', - details: {'code': 429}, + group('task_complete', () { + test('maps to CompletionResponse', () { + const event = TaskCompleteEvent( + lastAgentMessage: 'Done!', + params: {}, ); final responses = mapper.mapEvent(event); expect(responses, hasLength(1)); - expect(responses[0], isA()); - final error = responses[0] as ErrorResponse; - expect(error.error, 'rate limit exceeded'); - expect(error.details, contains('429')); - }); - - test('uses fallback message when error is null', () { - const event = TurnFailedEvent(); - final responses = mapper.mapEvent(event); - expect(responses, hasLength(1)); - expect((responses[0] as ErrorResponse).error, 'Turn failed'); + expect(responses[0], isA()); + expect((responses[0] as CompletionResponse).stopReason, 'completed'); }); }); @@ -98,67 +78,89 @@ void main() { group('unknown event', () { test('maps to empty list', () { - final event = UnknownCodexEvent({'type': 'future.thing'}); + const event = UnknownCodexEvent(method: 'future/thing', params: {}); final responses = mapper.mapEvent(event); expect(responses, isEmpty); }); }); - group('agent_message item', () { - test('maps completed event to TextResponse', () { - const event = ItemEvent( - eventType: 'item.completed', + group('token usage', () { + test('maps to CompletionResponse with usage data', () { + final event = TokenUsageUpdatedEvent( + usage: CodexUsage( + inputTokens: 100, + cachedInputTokens: 50, + outputTokens: 200, + ), + ); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + final completion = responses[0] as CompletionResponse; + expect(completion.stopReason, 'usage_update'); + expect(completion.inputTokens, 100); + expect(completion.outputTokens, 200); + expect(completion.cacheReadInputTokens, 50); + }); + }); + + group('agentMessage delta', () { + test('maps to non-cumulative TextResponse', () { + const event = AgentMessageDeltaEvent( itemId: 'msg_001', - itemType: 'agent_message', - data: {'text': 'Hello world'}, + delta: 'Hello ', ); final responses = mapper.mapEvent(event); expect(responses, hasLength(1)); expect(responses[0], isA()); final text = responses[0] as TextResponse; expect(text.id, 'msg_001'); - expect(text.content, 'Hello world'); - expect(text.isCumulative, isTrue); + expect(text.content, 'Hello '); + expect(text.isCumulative, isFalse); }); + }); - test('returns empty for non-completed event', () { - const event = ItemEvent( - eventType: 'item.started', + group('agentMessage item completed', () { + test('maps to cumulative TextResponse', () { + const event = ItemCompletedEvent( itemId: 'msg_001', - itemType: 'agent_message', - data: {'text': ''}, + itemType: 'agentMessage', + itemData: {'text': 'Hello world'}, ); - expect(mapper.mapEvent(event), isEmpty); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect(responses[0], isA()); + final text = responses[0] as TextResponse; + expect(text.id, 'msg_001'); + expect(text.content, 'Hello world'); + expect(text.isCumulative, isTrue); }); - test('returns empty for completed event with empty text', () { - const event = ItemEvent( - eventType: 'item.completed', + test('returns empty for empty text', () { + const event = ItemCompletedEvent( itemId: 'msg_001', - itemType: 'agent_message', - data: {'text': ''}, + itemType: 'agentMessage', + itemData: {'text': ''}, ); expect(mapper.mapEvent(event), isEmpty); }); - test('returns empty for completed event with no text key', () { - const event = ItemEvent( - eventType: 'item.completed', + test('returns empty for no text key', () { + const event = ItemCompletedEvent( itemId: 'msg_001', - itemType: 'agent_message', - data: {}, + itemType: 'agentMessage', + itemData: {}, ); expect(mapper.mapEvent(event), isEmpty); }); }); - group('command_execution item', () { + group('commandExecution item', () { test('maps started event to ToolUseResponse with Bash', () { - const event = ItemEvent( - eventType: 'item.started', + const event = ItemStartedEvent( itemId: 'cmd_001', - itemType: 'command_execution', - data: {'command': 'ls -la'}, + itemType: 'commandExecution', + itemData: {'command': 'ls -la'}, ); final responses = mapper.mapEvent(event); expect(responses, hasLength(1)); @@ -170,11 +172,10 @@ void main() { }); test('maps completed event to ToolResultResponse', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'cmd_001', - itemType: 'command_execution', - data: { + itemType: 'commandExecution', + itemData: { 'command': 'ls -la', 'exit_code': 0, 'aggregated_output': 'file1.dart\nfile2.dart', @@ -190,34 +191,32 @@ void main() { }); test('marks non-zero exit code as error', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'cmd_001', - itemType: 'command_execution', - data: {'exit_code': 1, 'aggregated_output': 'command not found'}, + itemType: 'commandExecution', + itemData: {'exit_code': 1, 'aggregated_output': 'command not found'}, ); final responses = mapper.mapEvent(event); expect((responses[0] as ToolResultResponse).isError, isTrue); }); - test('returns empty for updated event', () { - const event = ItemEvent( - eventType: 'item.updated', + test('falls back to output field when aggregated_output missing', () { + const event = ItemCompletedEvent( itemId: 'cmd_001', - itemType: 'command_execution', - data: {}, + itemType: 'commandExecution', + itemData: {'exit_code': 0, 'output': 'fallback output'}, ); - expect(mapper.mapEvent(event), isEmpty); + final responses = mapper.mapEvent(event); + expect((responses[0] as ToolResultResponse).content, 'fallback output'); }); }); - group('file_change item', () { + group('fileChange item', () { test('maps started event with changes to ToolUseResponse', () { - const event = ItemEvent( - eventType: 'item.started', + const event = ItemStartedEvent( itemId: 'file_001', - itemType: 'file_change', - data: { + itemType: 'fileChange', + itemData: { 'changes': [ {'path': 'lib/foo.dart', 'kind': 'add'}, ], @@ -233,11 +232,10 @@ void main() { }); test('infers Edit tool for update kind', () { - const event = ItemEvent( - eventType: 'item.started', + const event = ItemStartedEvent( itemId: 'file_001', - itemType: 'file_change', - data: { + itemType: 'fileChange', + itemData: { 'changes': [ {'path': 'lib/foo.dart', 'kind': 'update'}, ], @@ -247,27 +245,11 @@ void main() { expect((responses[0] as ToolUseResponse).toolName, 'Edit'); }); - test('infers Write tool for delete kind', () { - const event = ItemEvent( - eventType: 'item.started', - itemId: 'file_001', - itemType: 'file_change', - data: { - 'changes': [ - {'path': 'lib/old.dart', 'kind': 'delete'}, - ], - }, - ); - final responses = mapper.mapEvent(event); - expect((responses[0] as ToolUseResponse).toolName, 'Write'); - }); - test('maps completed event to summary ToolResultResponse', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'file_001', - itemType: 'file_change', - data: { + itemType: 'fileChange', + itemData: { 'changes': [ {'path': 'lib/foo.dart', 'kind': 'add'}, {'path': 'lib/bar.dart', 'kind': 'update'}, @@ -276,43 +258,28 @@ void main() { ); final responses = mapper.mapEvent(event); expect(responses, hasLength(1)); - expect(responses[0], isA()); final result = responses[0] as ToolResultResponse; expect(result.content, 'add: lib/foo.dart\nupdate: lib/bar.dart'); expect(result.isError, isFalse); }); - test('handles started event with no changes', () { - const event = ItemEvent( - eventType: 'item.started', - itemId: 'file_001', - itemType: 'file_change', - data: {}, - ); - final responses = mapper.mapEvent(event); - expect(responses, hasLength(1)); - expect((responses[0] as ToolUseResponse).toolName, 'Write'); - }); - test('handles completed event with no changes', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'file_001', - itemType: 'file_change', - data: {}, + itemType: 'fileChange', + itemData: {}, ); final responses = mapper.mapEvent(event); expect((responses[0] as ToolResultResponse).content, 'Done'); }); }); - group('mcp_tool_call item', () { - test('maps started event to ToolUseResponse with server prefix', () { - const event = ItemEvent( - eventType: 'item.started', + group('mcpToolCall item', () { + test('maps started event with server prefix', () { + const event = ItemStartedEvent( itemId: 'mcp_001', - itemType: 'mcp_tool_call', - data: { + itemType: 'mcpToolCall', + itemData: { 'server': 'vide-git', 'tool': 'gitStatus', 'arguments': {'detailed': true}, @@ -326,47 +293,46 @@ void main() { }); test('uses tool name alone when server is empty', () { - const event = ItemEvent( - eventType: 'item.started', + const event = ItemStartedEvent( itemId: 'mcp_001', - itemType: 'mcp_tool_call', - data: {'server': '', 'tool': 'someBuiltinTool', 'arguments': {}}, + itemType: 'mcpToolCall', + itemData: {'server': '', 'tool': 'someBuiltinTool', 'arguments': {}}, ); final responses = mapper.mapEvent(event); expect((responses[0] as ToolUseResponse).toolName, 'someBuiltinTool'); }); test('handles non-map arguments', () { - const event = ItemEvent( - eventType: 'item.started', + const event = ItemStartedEvent( itemId: 'mcp_001', - itemType: 'mcp_tool_call', - data: {'server': 'test', 'tool': 'myTool', 'arguments': 'not a map'}, + itemType: 'mcpToolCall', + itemData: { + 'server': 'test', + 'tool': 'myTool', + 'arguments': 'not a map', + }, ); final responses = mapper.mapEvent(event); expect((responses[0] as ToolUseResponse).parameters, isEmpty); }); - test('maps completed event with result to ToolResultResponse', () { - const event = ItemEvent( - eventType: 'item.completed', + test('maps completed event with string result', () { + const event = ItemCompletedEvent( itemId: 'mcp_001', - itemType: 'mcp_tool_call', - data: {'result': 'some output'}, + itemType: 'mcpToolCall', + itemData: {'result': 'some output'}, ); final responses = mapper.mapEvent(event); - expect(responses, hasLength(1)); final result = responses[0] as ToolResultResponse; expect(result.content, 'some output'); expect(result.isError, isFalse); }); test('maps completed event with content block array result', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'mcp_001', - itemType: 'mcp_tool_call', - data: { + itemType: 'mcpToolCall', + itemData: { 'result': [ {'type': 'text', 'text': 'line 1'}, {'type': 'text', 'text': 'line 2'}, @@ -379,98 +345,74 @@ void main() { }); test('maps completed event with error', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'mcp_001', - itemType: 'mcp_tool_call', - data: {'error': 'tool not found'}, + itemType: 'mcpToolCall', + itemData: {'error': 'tool not found'}, ); final responses = mapper.mapEvent(event); final result = responses[0] as ToolResultResponse; expect(result.content, 'tool not found'); expect(result.isError, isTrue); }); - - test('maps completed event with structured error', () { - const event = ItemEvent( - eventType: 'item.completed', - itemId: 'mcp_001', - itemType: 'mcp_tool_call', - data: { - 'error': {'message': 'permission denied', 'code': 403}, - }, - ); - final responses = mapper.mapEvent(event); - final result = responses[0] as ToolResultResponse; - expect(result.content, 'permission denied'); - expect(result.isError, isTrue); - }); - - test('returns empty for updated event', () { - const event = ItemEvent( - eventType: 'item.updated', - itemId: 'mcp_001', - itemType: 'mcp_tool_call', - data: {}, - ); - expect(mapper.mapEvent(event), isEmpty); - }); }); group('reasoning item', () { test('maps completed event to TextResponse', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'reason_001', itemType: 'reasoning', - data: {'text': 'Let me think about this...'}, + itemData: {'text': 'Let me think...'}, ); final responses = mapper.mapEvent(event); expect(responses, hasLength(1)); final text = responses[0] as TextResponse; - expect(text.content, 'Let me think about this...'); + expect(text.content, 'Let me think...'); expect(text.isCumulative, isTrue); }); test('falls back to summary field', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'reason_001', itemType: 'reasoning', - data: {'summary': 'Thinking summary'}, + itemData: {'summary': 'Thinking summary'}, ); final responses = mapper.mapEvent(event); expect((responses[0] as TextResponse).content, 'Thinking summary'); }); - test('returns empty for non-completed event', () { - const event = ItemEvent( - eventType: 'item.started', + test('handles summary as list of content blocks', () { + const event = ItemCompletedEvent( itemId: 'reason_001', itemType: 'reasoning', - data: {}, + itemData: { + 'summary': [ + {'type': 'summary_text', 'text': 'Thinking about it'}, + ], + }, ); - expect(mapper.mapEvent(event), isEmpty); + final responses = mapper.mapEvent(event); + expect(responses, hasLength(1)); + expect( + (responses[0] as TextResponse).content, 'Thinking about it'); }); test('returns empty for empty text', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'reason_001', itemType: 'reasoning', - data: {'text': ''}, + itemData: {'text': ''}, ); expect(mapper.mapEvent(event), isEmpty); }); }); - group('web_search item', () { + group('webSearch item', () { test('maps started event to WebSearch ToolUseResponse', () { - const event = ItemEvent( - eventType: 'item.started', + const event = ItemStartedEvent( itemId: 'search_001', - itemType: 'web_search', - data: {'query': 'dart async patterns'}, + itemType: 'webSearch', + itemData: {'query': 'dart async patterns'}, ); final responses = mapper.mapEvent(event); expect(responses, hasLength(1)); @@ -480,96 +422,85 @@ void main() { }); test('maps completed event to ToolResultResponse', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'search_001', - itemType: 'web_search', - data: {}, + itemType: 'webSearch', + itemData: {}, ); final responses = mapper.mapEvent(event); - expect(responses, hasLength(1)); final result = responses[0] as ToolResultResponse; expect(result.content, 'Search complete'); expect(result.isError, isFalse); }); - - test('returns empty for updated event', () { - const event = ItemEvent( - eventType: 'item.updated', - itemId: 'search_001', - itemType: 'web_search', - data: {}, - ); - expect(mapper.mapEvent(event), isEmpty); - }); }); - group('todo_list item', () { + group('todoList item', () { test('maps completed event to checklist TextResponse', () { - const event = ItemEvent( - eventType: 'item.completed', + const event = ItemCompletedEvent( itemId: 'todo_001', - itemType: 'todo_list', - data: { + itemType: 'todoList', + itemData: { 'items': [ {'text': 'Write tests', 'completed': true}, {'text': 'Fix bugs', 'completed': false}, - {'text': 'Deploy', 'completed': false}, ], }, ); final responses = mapper.mapEvent(event); expect(responses, hasLength(1)); final text = responses[0] as TextResponse; - expect(text.content, '[x] Write tests\n[ ] Fix bugs\n[ ] Deploy'); + expect(text.content, '[x] Write tests\n[ ] Fix bugs'); expect(text.isCumulative, isTrue); }); - test('maps updated event to checklist TextResponse', () { - const event = ItemEvent( - eventType: 'item.updated', + test('returns empty for empty items list', () { + const event = ItemCompletedEvent( itemId: 'todo_001', - itemType: 'todo_list', - data: { - 'items': [ - {'text': 'Write tests', 'completed': true}, - ], - }, + itemType: 'todoList', + itemData: {'items': []}, ); - final responses = mapper.mapEvent(event); - expect(responses, hasLength(1)); - expect((responses[0] as TextResponse).content, '[x] Write tests'); + expect(mapper.mapEvent(event), isEmpty); }); + }); - test('returns empty for started event', () { - const event = ItemEvent( - eventType: 'item.started', - itemId: 'todo_001', - itemType: 'todo_list', - data: {'items': []}, + group('unknown item type', () { + test('returns empty list for started', () { + const event = ItemStartedEvent( + itemId: 'x_001', + itemType: 'unknownFutureType', + itemData: {}, ); expect(mapper.mapEvent(event), isEmpty); }); - test('returns empty for empty items list', () { - const event = ItemEvent( - eventType: 'item.completed', - itemId: 'todo_001', - itemType: 'todo_list', - data: {'items': []}, + test('returns empty list for completed', () { + const event = ItemCompletedEvent( + itemId: 'x_001', + itemType: 'unknownFutureType', + itemData: {}, ); expect(mapper.mapEvent(event), isEmpty); }); }); - group('unknown item type', () { - test('returns empty list', () { - const event = ItemEvent( - eventType: 'item.completed', - itemId: 'x_001', - itemType: 'unknown_future_type', - data: {}, - ); + group('events that map to empty', () { + test('ThreadNameUpdatedEvent maps to empty', () { + const event = ThreadNameUpdatedEvent(threadId: 't', name: 'n'); + expect(mapper.mapEvent(event), isEmpty); + }); + + test('McpStartupCompleteEvent maps to empty', () { + const event = McpStartupCompleteEvent(); + expect(mapper.mapEvent(event), isEmpty); + }); + + test('ReasoningSummaryDeltaEvent maps to empty', () { + const event = ReasoningSummaryDeltaEvent(itemId: 'r', delta: 'x'); + expect(mapper.mapEvent(event), isEmpty); + }); + + test('CommandOutputDeltaEvent maps to empty', () { + const event = CommandOutputDeltaEvent(itemId: 'c', delta: 'x'); expect(mapper.mapEvent(event), isEmpty); }); }); @@ -579,7 +510,7 @@ void main() { test('generates unique IDs across events', () { final ids = {}; for (var i = 0; i < 5; i++) { - const event = TurnStartedEvent(); + const event = TurnStartedEvent(turnId: '0', turnData: {}); final responses = mapper.mapEvent(event); ids.add(responses[0].id); } diff --git a/packages/codex_sdk/test/codex_event_parser_test.dart b/packages/codex_sdk/test/codex_event_parser_test.dart index 439fdc6..a157f77 100644 --- a/packages/codex_sdk/test/codex_event_parser_test.dart +++ b/packages/codex_sdk/test/codex_event_parser_test.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:codex_sdk/codex_sdk.dart'; import 'package:test/test.dart'; @@ -10,208 +8,184 @@ void main() { parser = CodexEventParser(); }); - group('CodexEventParser.parseLine', () { - test('returns null for empty string', () { - expect(parser.parseLine(''), isNull); - }); - - test('returns null for whitespace-only string', () { - expect(parser.parseLine(' \t '), isNull); - }); - - test('returns null for invalid JSON', () { - expect(parser.parseLine('not json at all'), isNull); - }); - - test('returns null for JSON array (not object)', () { - expect(parser.parseLine('[1, 2, 3]'), isNull); - }); - - test('parses thread.started event', () { - final json = jsonEncode({ - 'type': 'thread.started', - 'thread_id': 'thread_abc123', - }); - final event = parser.parseLine(json); + group('CodexEventParser.parseNotification', () { + test('parses thread/started', () { + final notification = JsonRpcNotification( + method: 'thread/started', + params: { + 'thread': {'id': 'thread_abc123'}, + }, + ); + final event = parser.parseNotification(notification); expect(event, isA()); expect((event as ThreadStartedEvent).threadId, 'thread_abc123'); }); - test('parses turn.started event', () { - final json = jsonEncode({'type': 'turn.started'}); - final event = parser.parseLine(json); + test('parses turn/started', () { + final notification = JsonRpcNotification( + method: 'turn/started', + params: { + 'turn': {'id': '0'}, + }, + ); + final event = parser.parseNotification(notification); expect(event, isA()); + expect((event as TurnStartedEvent).turnId, '0'); }); - test('parses turn.completed event with usage', () { - final json = jsonEncode({ - 'type': 'turn.completed', - 'usage': { - 'input_tokens': 100, - 'cached_input_tokens': 50, - 'output_tokens': 200, + test('parses turn/completed', () { + final notification = JsonRpcNotification( + method: 'turn/completed', + params: { + 'turn': {'id': '0', 'status': 'completed'}, }, - }); - final event = parser.parseLine(json); + ); + final event = parser.parseNotification(notification); expect(event, isA()); final completed = event as TurnCompletedEvent; - expect(completed.usage!.inputTokens, 100); - expect(completed.usage!.cachedInputTokens, 50); - expect(completed.usage!.outputTokens, 200); - }); - - test('parses turn.completed event without usage', () { - final json = jsonEncode({'type': 'turn.completed'}); - final event = parser.parseLine(json); - expect(event, isA()); - expect((event as TurnCompletedEvent).usage, isNull); - }); - - test('parses turn.failed with string error', () { - final json = jsonEncode({ - 'type': 'turn.failed', - 'error': 'something went wrong', - }); - final event = parser.parseLine(json); - expect(event, isA()); - final failed = event as TurnFailedEvent; - expect(failed.error, 'something went wrong'); - expect(failed.details, isNull); - }); - - test('parses turn.failed with structured error', () { - final json = jsonEncode({ - 'type': 'turn.failed', - 'error': {'message': 'rate limit', 'code': 429}, - }); - final event = parser.parseLine(json); - expect(event, isA()); - final failed = event as TurnFailedEvent; - expect(failed.error, 'rate limit'); - expect(failed.details!['code'], 429); - }); - - test('parses item.started event', () { - final json = jsonEncode({ - 'type': 'item.started', - 'item': { - 'id': 'item_001', - 'type': 'agent_message', - 'status': 'in_progress', - 'text': '', + expect(completed.turnId, '0'); + expect(completed.status, 'completed'); + }); + + test('parses item/started', () { + final notification = JsonRpcNotification( + method: 'item/started', + params: { + 'item': { + 'id': 'item_001', + 'type': 'agentMessage', + }, }, - }); - final event = parser.parseLine(json); - expect(event, isA()); - final item = event as ItemEvent; - expect(item.eventType, 'item.started'); + ); + final event = parser.parseNotification(notification); + expect(event, isA()); + final item = event as ItemStartedEvent; expect(item.itemId, 'item_001'); - expect(item.itemType, 'agent_message'); - expect(item.isStarted, isTrue); - expect(item.isCompleted, isFalse); - expect(item.isUpdated, isFalse); - }); - - test('parses item.completed event', () { - final json = jsonEncode({ - 'type': 'item.completed', - 'item': { - 'id': 'item_001', - 'type': 'agent_message', - 'status': 'completed', - 'text': 'Hello!', + expect(item.itemType, 'agentMessage'); + }); + + test('parses item/completed', () { + final notification = JsonRpcNotification( + method: 'item/completed', + params: { + 'item': { + 'id': 'item_001', + 'type': 'agentMessage', + 'text': 'Hello!', + }, }, - }); - final event = parser.parseLine(json); - expect(event, isA()); - final item = event as ItemEvent; - expect(item.isCompleted, isTrue); - expect(item.data['text'], 'Hello!'); - }); - - test('parses item.updated event', () { - final json = jsonEncode({ - 'type': 'item.updated', - 'item': {'id': 'item_001', 'type': 'todo_list', 'items': []}, - }); - final event = parser.parseLine(json); - expect(event, isA()); - expect((event as ItemEvent).isUpdated, isTrue); - }); - - test('parses error event with string message', () { - final json = jsonEncode({'type': 'error', 'error': 'connection failed'}); - final event = parser.parseLine(json); - expect(event, isA()); - expect((event as CodexErrorEvent).message, 'connection failed'); + ); + final event = parser.parseNotification(notification); + expect(event, isA()); + final item = event as ItemCompletedEvent; + expect(item.itemId, 'item_001'); + expect(item.itemData['text'], 'Hello!'); }); - test('parses error event with structured error', () { - final json = jsonEncode({ - 'type': 'error', - 'error': {'message': 'timeout', 'code': 504}, - }); - final event = parser.parseLine(json); + test('parses item/agentMessage/delta', () { + final notification = JsonRpcNotification( + method: 'item/agentMessage/delta', + params: { + 'itemId': 'item_001', + 'delta': 'Hello ', + }, + ); + final event = parser.parseNotification(notification); + expect(event, isA()); + final delta = event as AgentMessageDeltaEvent; + expect(delta.itemId, 'item_001'); + expect(delta.delta, 'Hello '); + }); + + test('parses item/reasoning/summaryTextDelta', () { + final notification = JsonRpcNotification( + method: 'item/reasoning/summaryTextDelta', + params: { + 'itemId': 'r_001', + 'delta': 'thinking...', + }, + ); + final event = parser.parseNotification(notification); + expect(event, isA()); + expect((event as ReasoningSummaryDeltaEvent).delta, 'thinking...'); + }); + + test('parses item/commandExecution/outputDelta', () { + final notification = JsonRpcNotification( + method: 'item/commandExecution/outputDelta', + params: { + 'itemId': 'cmd_001', + 'delta': 'output line', + }, + ); + final event = parser.parseNotification(notification); + expect(event, isA()); + expect((event as CommandOutputDeltaEvent).delta, 'output line'); + }); + + test('parses thread/tokenUsage/updated', () { + final notification = JsonRpcNotification( + method: 'thread/tokenUsage/updated', + params: { + 'usage': { + 'input_tokens': 100, + 'cached_input_tokens': 50, + 'output_tokens': 200, + }, + }, + ); + final event = parser.parseNotification(notification); + expect(event, isA()); + final usage = (event as TokenUsageUpdatedEvent).usage; + expect(usage.inputTokens, 100); + expect(usage.cachedInputTokens, 50); + expect(usage.outputTokens, 200); + }); + + test('parses codex/event/task_complete', () { + final notification = JsonRpcNotification( + method: 'codex/event/task_complete', + params: { + 'msg': { + 'type': 'task_complete', + 'last_agent_message': 'Done!', + }, + }, + ); + final event = parser.parseNotification(notification); + expect(event, isA()); + expect((event as TaskCompleteEvent).lastAgentMessage, 'Done!'); + }); + + test('parses codex/event/mcp_startup_complete', () { + final notification = JsonRpcNotification( + method: 'codex/event/mcp_startup_complete', + params: {}, + ); + final event = parser.parseNotification(notification); + expect(event, isA()); + }); + + test('parses error notification', () { + final notification = JsonRpcNotification( + method: 'error', + params: {'message': 'something broke'}, + ); + final event = parser.parseNotification(notification); expect(event, isA()); - final error = event as CodexErrorEvent; - expect(error.message, 'timeout'); - expect(error.details!['code'], 504); + expect((event as CodexErrorEvent).message, 'something broke'); }); - test('parses unknown event type', () { - final json = jsonEncode({'type': 'future.event', 'data': 42}); - final event = parser.parseLine(json); + test('returns UnknownCodexEvent for unrecognized method', () { + final notification = JsonRpcNotification( + method: 'future/unknown/event', + params: {'data': 42}, + ); + final event = parser.parseNotification(notification); expect(event, isA()); - expect((event as UnknownCodexEvent).rawData['type'], 'future.event'); - }); - - test('trims whitespace before parsing', () { - final json = ' ${jsonEncode({'type': 'turn.started'})} '; - final event = parser.parseLine(json); - expect(event, isA()); - }); - }); - - group('CodexEventParser.parseChunk', () { - test('parses multiple lines', () { - final chunk = [ - jsonEncode({'type': 'thread.started', 'thread_id': 't1'}), - jsonEncode({'type': 'turn.started'}), - jsonEncode({'type': 'turn.completed'}), - ].join('\n'); - - final events = parser.parseChunk(chunk); - expect(events, hasLength(3)); - expect(events[0], isA()); - expect(events[1], isA()); - expect(events[2], isA()); - }); - - test('skips empty lines in chunk', () { - final chunk = [ - jsonEncode({'type': 'turn.started'}), - '', - '', - jsonEncode({'type': 'turn.completed'}), - ].join('\n'); - - final events = parser.parseChunk(chunk); - expect(events, hasLength(2)); - }); - - test('skips unparseable lines in chunk', () { - final chunk = [ - jsonEncode({'type': 'turn.started'}), - 'not json', - jsonEncode({'type': 'turn.completed'}), - ].join('\n'); - - final events = parser.parseChunk(chunk); - expect(events, hasLength(2)); - }); - - test('returns empty list for empty chunk', () { - expect(parser.parseChunk(''), isEmpty); + final unknown = event as UnknownCodexEvent; + expect(unknown.method, 'future/unknown/event'); + expect(unknown.params['data'], 42); }); }); } diff --git a/packages/codex_sdk/test/json_rpc_message_test.dart b/packages/codex_sdk/test/json_rpc_message_test.dart new file mode 100644 index 0000000..1d92a2f --- /dev/null +++ b/packages/codex_sdk/test/json_rpc_message_test.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'package:codex_sdk/codex_sdk.dart'; +import 'package:test/test.dart'; + +void main() { + group('JsonRpcMessage.fromJson', () { + test('parses notification (method, no id)', () { + final json = {'method': 'turn/started', 'params': {'turn': {}}}; + final msg = JsonRpcMessage.fromJson(json); + expect(msg, isA()); + final notif = msg as JsonRpcNotification; + expect(notif.method, 'turn/started'); + expect(notif.params, {'turn': {}}); + }); + + test('parses request (method + id)', () { + final json = { + 'method': 'item/commandExecution/requestApproval', + 'id': 5, + 'params': {'command': 'rm -rf /'}, + }; + final msg = JsonRpcMessage.fromJson(json); + expect(msg, isA()); + final req = msg as JsonRpcRequest; + expect(req.id, 5); + expect(req.method, 'item/commandExecution/requestApproval'); + expect(req.params['command'], 'rm -rf /'); + }); + + test('parses response with result (id + result, no method)', () { + final json = { + 'id': 0, + 'result': {'userAgent': 'codex/0.98.0'}, + }; + final msg = JsonRpcMessage.fromJson(json); + expect(msg, isA()); + final resp = msg as JsonRpcResponse; + expect(resp.id, 0); + expect(resp.result?['userAgent'], 'codex/0.98.0'); + expect(resp.isError, isFalse); + }); + + test('parses error response', () { + final json = { + 'id': 1, + 'error': {'code': -32600, 'message': 'Invalid Request'}, + }; + final msg = JsonRpcMessage.fromJson(json); + expect(msg, isA()); + final resp = msg as JsonRpcResponse; + expect(resp.isError, isTrue); + expect(resp.error!.code, -32600); + expect(resp.error!.message, 'Invalid Request'); + }); + + test('parses response with string id', () { + final json = { + 'id': 'abc-123', + 'result': {}, + }; + final msg = JsonRpcMessage.fromJson(json); + expect(msg, isA()); + expect((msg as JsonRpcResponse).id, 'abc-123'); + }); + + test('notification defaults to empty params', () { + final json = {'method': 'test/event'}; + final msg = JsonRpcMessage.fromJson(json); + expect(msg, isA()); + expect((msg as JsonRpcNotification).params, isEmpty); + }); + }); + + group('JsonRpcMessage.parseLine', () { + test('parses valid JSONL', () { + final line = jsonEncode({'method': 'turn/started', 'params': {}}); + final msg = JsonRpcMessage.parseLine(line); + expect(msg, isA()); + }); + + test('returns null for empty line', () { + expect(JsonRpcMessage.parseLine(''), isNull); + }); + + test('returns null for whitespace', () { + expect(JsonRpcMessage.parseLine(' \t '), isNull); + }); + + test('returns null for invalid JSON', () { + expect(JsonRpcMessage.parseLine('not json'), isNull); + }); + + test('returns null for JSON array', () { + expect(JsonRpcMessage.parseLine('[1, 2]'), isNull); + }); + + test('trims whitespace before parsing', () { + final line = ' ${jsonEncode({'method': 'test', 'params': {}})} '; + final msg = JsonRpcMessage.parseLine(line); + expect(msg, isA()); + }); + }); + + group('JsonRpcError', () { + test('parses from JSON', () { + final json = {'code': -32601, 'message': 'Method not found', 'data': 42}; + final error = JsonRpcError.fromJson(json); + expect(error.code, -32601); + expect(error.message, 'Method not found'); + expect(error.data, 42); + }); + + test('defaults missing fields', () { + final error = JsonRpcError.fromJson({}); + expect(error.code, -1); + expect(error.message, 'Unknown error'); + expect(error.data, isNull); + }); + }); +}