import 'dart:convert'; import 'dart:io'; import 'package:code_work_spawner/src/config/config.dart'; import 'package:code_work_spawner/src/core/cli_responder.dart'; import 'package:code_work_spawner/src/issue_tracker/issue_tracker_client.dart'; final Map _fakeResponders = {}; CliResponderRunner fakeResponderRunner() => FakeResponderRunner(Map.from(_fakeResponders)); Future writeResponderScript( File responderScript, Map response, { String stderr = '', int exitCode = 0, }) async { _fakeResponders[responderScript.path] = _FakeResponder( response: response, stderr: stderr, exitCode: exitCode, ); } Future writeCapturingResponderScript( File responderScript, Map response, { File? cwdCapture, File? stdinCapture, }) async { _fakeResponders[responderScript.path] = _FakeResponder( response: response, cwdCapture: cwdCapture, stdinCapture: stdinCapture, ); } Future writeJsonlResponderScript( File responderScript, Map response, ) async { _fakeResponders[responderScript.path] = _FakeResponder( response: response, emitsJsonl: true, ); } Future writeDelayedLoggingResponderScript( File responderScript, { required File eventLog, required Duration delay, required Map response, }) async { _fakeResponders[responderScript.path] = _FakeResponder( response: response, eventLog: eventLog, delay: delay, ); } class FakeResponderRunner extends CliResponderRunner { FakeResponderRunner(this._responders); final Map _responders; @override Future run({ required CliResponderConfig responder, required ProjectConfig project, required IssueThread thread, required String prompt, required String workspacePath, }) async { final fake = _responders[responder.command]; if (fake == null) { throw StateError( 'No fake responder registered for command ${responder.command}.', ); } 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()), }; final stdinPayload = renderTemplate(responder.stdinTemplate, context); await fake.run( issueNumber: thread.issue.number, stdinPayload: stdinPayload, workspacePath: workspacePath, ); final rawStdout = fake.rawStdout; if (fake.exitCode != 0) { throw CliResponderExecutionException( responderId: responder.id, command: responder.command, arguments: responder.args, exitCode: fake.exitCode, rawStdout: rawStdout, rawStderr: fake.stderr, ); } return ResponderResult.fromJson( fake.response, responderId: responder.id, rawStdout: rawStdout, rawStderr: fake.stderr, ); } } class _FakeResponder { _FakeResponder({ required this.response, this.stderr = '', this.exitCode = 0, this.emitsJsonl = false, this.eventLog, this.delay = Duration.zero, this.cwdCapture, this.stdinCapture, }); final Map response; final String stderr; final int exitCode; final bool emitsJsonl; final File? eventLog; final Duration delay; final File? cwdCapture; final File? stdinCapture; String get rawStdout { if (!emitsJsonl) { return jsonEncode(response); } return [ jsonEncode({'type': 'thread.started', 'thread_id': 'test-thread'}), jsonEncode({'type': 'turn.started'}), jsonEncode({ 'type': 'item.completed', 'item': { 'id': 'item_0', 'type': 'agent_message', 'text': jsonEncode(response), }, }), jsonEncode({'type': 'turn.completed'}), '', ].join('\n'); } Future run({ required int issueNumber, required String stdinPayload, required String workspacePath, }) async { await cwdCapture?.writeAsString(workspacePath); await stdinCapture?.writeAsString(stdinPayload); if (eventLog != null) { await _appendEvent('start', issueNumber); await Future.delayed(delay); await _appendEvent('end', issueNumber); } } Future _appendEvent(String phase, int issueNumber) async { final timestampMs = DateTime.now().microsecondsSinceEpoch ~/ 1000; final line = '$phase:$issueNumber:$timestampMs\n'; for (var attempt = 0; attempt < 200; attempt += 1) { RandomAccessFile? log; try { log = eventLog!.openSync(mode: FileMode.append); log.lockSync(FileLock.exclusive); log.writeStringSync(line); log.unlockSync(); return; } on FileSystemException { await Future.delayed(const Duration(milliseconds: 5)); } finally { log?.closeSync(); } } await eventLog!.writeAsString(line, mode: FileMode.append); } }