import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'config.dart'; import 'issue_tracker_client.dart'; class CliResponderRunner { Future run({ required CliResponderConfig responder, required ProjectConfig project, required IssueThread thread, required String prompt, required bool forceReply, required String workspacePath, }) async { final context = { 'prompt': prompt, 'repo': project.repo, 'project_key': project.key, 'project_path': project.path, 'workspace_path': workspacePath, 'issue_number': thread.issue.number.toString(), 'thread_json': encodePrettyJson(thread.toJson()), 'force_reply': forceReply.toString(), }; final stdinPayload = renderTemplate(responder.stdinTemplate, context); final process = await Process.start( responder.command, responder.args, workingDirectory: workspacePath, environment: { ...Platform.environment, ...responder.env, 'CWS_PROJECT_KEY': project.key, 'CWS_REPO': project.repo, 'CWS_PROJECT_PATH': project.path, 'CWS_WORKSPACE_PATH': workspacePath, 'CWS_ISSUE_NUMBER': thread.issue.number.toString(), 'CWS_FORCE_REPLY': forceReply.toString(), }, ); process.stdin.write(stdinPayload); await process.stdin.close(); final stdoutFuture = process.stdout.transform(utf8.decoder).join(); final stderrFuture = process.stderr.transform(utf8.decoder).join(); late final int exitCode; try { exitCode = await process.exitCode.timeout(responder.timeout); } on TimeoutException { process.kill(ProcessSignal.sigkill); throw TimeoutException( 'Responder "${responder.id}" timed out after ${responder.timeout}.', ); } final stdoutText = await stdoutFuture; final stderrText = await stderrFuture; if (exitCode != 0) { throw CliResponderExecutionException( responderId: responder.id, command: responder.command, arguments: responder.args, exitCode: exitCode, rawStdout: stdoutText, rawStderr: stderrText, ); } final decoded = _decodeJson(stdoutText); return ResponderResult.fromJson( decoded, responderId: responder.id, rawStdout: stdoutText, rawStderr: stderrText, ); } Map _decodeJson(String stdout) { final trimmed = stdout.trim(); if (trimmed.isEmpty) { throw const FormatException('Responder returned empty stdout.'); } try { return (jsonDecode(trimmed) as Map).cast(); } on FormatException { final eventJson = _decodeJsonlEvents(trimmed); if (eventJson != null) { return eventJson; } final first = trimmed.indexOf('{'); final last = trimmed.lastIndexOf('}'); if (first == -1 || last <= first) { rethrow; } final snippet = trimmed.substring(first, last + 1); return (jsonDecode(snippet) as Map).cast(); } } Map? _decodeJsonlEvents(String stdout) { Map? lastEvent; for (final line in const LineSplitter().convert(stdout)) { final trimmed = line.trim(); if (trimmed.isEmpty || !trimmed.startsWith('{')) { continue; } try { final decoded = jsonDecode(trimmed); if (decoded is! Map) { continue; } lastEvent = decoded.cast(); final item = lastEvent['item']; if (item is Map && item['type']?.toString() == 'agent_message' && item['text'] is String) { final text = (item['text'] as String).trim(); if (text.startsWith('{')) { return (jsonDecode(text) as Map).cast(); } } } on FormatException { continue; } } if (lastEvent case {'text': final String text}) { final trimmed = text.trim(); if (trimmed.startsWith('{')) { return (jsonDecode(trimmed) as Map).cast(); } } return null; } } class CliResponderExecutionException implements Exception { CliResponderExecutionException({ required this.responderId, required this.command, required this.arguments, required this.exitCode, required this.rawStdout, required this.rawStderr, }); final String responderId; final String command; final List arguments; final int exitCode; final String rawStdout; final String rawStderr; @override String toString() { final details = rawStderr.isEmpty ? rawStdout : rawStderr; return 'CliResponderExecutionException(responderId: $responderId, ' 'command: $command, exitCode: $exitCode, details: $details)'; } } class ResponderResult { ResponderResult({ required this.responderId, required this.decision, required this.mode, required this.markdown, required this.summary, required this.rawStdout, required this.rawStderr, }); final String responderId; final String decision; final String mode; final String markdown; final String summary; final String rawStdout; final String rawStderr; bool get shouldReply => decision == 'reply'; ResponderResult forceReply() { final fallbackMarkdown = markdown.isNotEmpty ? markdown : summary.isNotEmpty ? summary : 'I reviewed the latest mention but could not produce a detailed response.'; return ResponderResult( responderId: responderId, decision: 'reply', mode: mode, markdown: fallbackMarkdown, summary: summary, rawStdout: rawStdout, rawStderr: rawStderr, ); } factory ResponderResult.fromJson( Map json, { required String responderId, required String rawStdout, required String rawStderr, }) { final decision = json['decision']?.toString() ?? 'no_reply'; final mode = json['mode']?.toString() ?? 'planning'; final markdown = json['markdown']?.toString() ?? ''; final summary = json['summary']?.toString() ?? ''; if (decision != 'reply' && decision != 'no_reply') { throw FormatException('Invalid decision "$decision".'); } return ResponderResult( responderId: responderId, decision: decision, mode: mode, markdown: markdown, summary: summary, rawStdout: rawStdout, rawStderr: rawStderr, ); } }