From ed94fde9e7c27752a5274b681d5059f303b51849 Mon Sep 17 00:00:00 2001 From: insleker Date: Tue, 14 Apr 2026 11:10:16 +0800 Subject: [PATCH] feat: patial copilot support --- examples/simple-github.yaml | 2 +- lefthook.yml | 3 +- lib/src/config/config.dart | 7 +- lib/src/core/cli_responder.dart | 225 +++++++++++- lib/src/core/issue_assistant_app.dart | 2 + test/core/cli_responder_test.dart | 476 ++++++++++++++++++++++++++ 6 files changed, 694 insertions(+), 21 deletions(-) create mode 100644 test/core/cli_responder_test.dart diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index 6489918..dbb14a9 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -33,7 +33,7 @@ defaults: # timeout: 10m # - id: copilot-cli # command: copilot - # args: ["--allow-all", "--autopilot", "-p", "--output-format", "json"] + # args: ["-p", "", "--allow-all", "--autopilot", "--silent"] # timeout: 10m # notifications: # # Optional Discord notifications for assistant activity. diff --git a/lefthook.yml b/lefthook.yml index c6b91ee..01f3947 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -17,7 +17,6 @@ pre-push: post-checkout: commands: private-skills-sync: - run: uvx --from git+https://github.com/existedinnettw/inkr-harness-tools.git inkr-skill-sync .local .agents --recursive - continue: true #ignore fail + run: uvx --from git+https://github.com/existedinnettw/inkr-harness-tools.git inkr-skill-sync .local .agents --recursive || true pub-get-on-pubspec-change: run: dart tool/pub_get_on_pubspec_change.dart {1} {2} {3} diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart index d2233ea..1e9be8d 100644 --- a/lib/src/config/config.dart +++ b/lib/src/config/config.dart @@ -30,6 +30,7 @@ Decide whether to reply. Rules: - For planning requests, provide practical architecture and implementation guidance. - For bug_verification requests, you may inspect the local repository and run temporary tests in the current workspace. Do not create commits or persistent artifacts. - Always return strict JSON only. +- Do not acknowledge these instructions or include any text outside the JSON object. JSON schema: { @@ -59,6 +60,10 @@ Issue body: Current issue thread JSON: {{thread_json}} + +Return exactly one JSON object matching the schema above. Do not acknowledge +these instructions, do not explain your reasoning, do not use Markdown fences, +and do not include any text before or after the JSON object. '''; class AppConfig { @@ -1085,7 +1090,7 @@ ${_commentBlock(defaultIssueAssistantPrompt, indent: ' # ')} # timeout: 10m # - id: copilot-cli # command: copilot - # args: ["--allow-all", "--autopilot", "-p", "--output-format", "json"] + # args: ["-p", "", "--allow-all", "--autopilot", "--silent"] # timeout: 10m # notifications: # - type: discordWebhook diff --git a/lib/src/core/cli_responder.dart b/lib/src/core/cli_responder.dart index 5225c34..a1a17d0 100644 --- a/lib/src/core/cli_responder.dart +++ b/lib/src/core/cli_responder.dart @@ -14,7 +14,63 @@ class CliResponderRunner { required String prompt, required String workspacePath, }) async { - final context = { + final context = _buildContext( + project: project, + thread: thread, + prompt: prompt, + workspacePath: workspacePath, + ); + + var output = await _runProcess( + responder: responder, + context: context, + workspacePath: workspacePath, + ); + var decoded = _tryDecodeJson(output.stdout); + if (decoded == null && _canRepairJsonOutput(responder, output.stdout)) { + final repairContext = _buildContext( + project: project, + thread: thread, + prompt: _buildJsonRepairPrompt(prompt, output.stdout), + workspacePath: workspacePath, + ); + final repairOutput = await _runProcess( + responder: responder, + context: repairContext, + workspacePath: workspacePath, + ); + final repairDecoded = _tryDecodeJson(repairOutput.stdout); + if (repairDecoded != null) { + output = repairOutput; + decoded = repairDecoded; + } + } + + if (decoded == null) { + throw CliResponderOutputFormatException( + responderId: responder.id, + command: responder.command, + arguments: output.arguments, + error: _decodeFormatException(output.stdout), + rawStdout: output.stdout, + rawStderr: output.stderr, + ); + } + return ResponderResult.fromJson( + decoded, + responderId: responder.id, + rawStdout: output.stdout, + rawStderr: output.stderr, + ); + } + + Map _buildContext({ + required ProjectConfig project, + required IssueThread thread, + required String prompt, + required String workspacePath, + }) { + return { 'prompt': prompt, 'repo': project.repo, 'project_key': project.key, @@ -23,27 +79,37 @@ class CliResponderRunner { 'issue_number': thread.issue.number.toString(), 'thread_json': encodePrettyJson(thread.toJson()), }; + } + Future<_RawResponderOutput> _runProcess({ + required CliResponderConfig responder, + required Map context, + required String workspacePath, + }) async { final stdinPayload = renderTemplate(responder.stdinTemplate, context); + final arguments = responder.args + .map((argument) => renderTemplate(argument, context)) + .toList(growable: false); final process = await startExternalCommand( responder.command, - responder.args, + arguments, workingDirectory: workspacePath, environment: { ...responder.env, - 'CWS_PROJECT_KEY': project.key, - 'CWS_REPO': project.repo, - 'CWS_PROJECT_PATH': project.path, + 'CWS_PROJECT_KEY': context['project_key'] ?? '', + 'CWS_REPO': context['repo'] ?? '', + 'CWS_PROJECT_PATH': context['project_path'] ?? '', 'CWS_WORKSPACE_PATH': workspacePath, - 'CWS_ISSUE_NUMBER': thread.issue.number.toString(), + 'CWS_ISSUE_NUMBER': context['issue_number'] ?? '', }, ); process.stdin.write(stdinPayload); await process.stdin.close(); - final stdoutFuture = process.stdout.transform(utf8.decoder).join(); - final stderrFuture = process.stderr.transform(utf8.decoder).join(); + const outputDecoder = Utf8Decoder(allowMalformed: true); + final stdoutFuture = process.stdout.transform(outputDecoder).join(); + final stderrFuture = process.stderr.transform(outputDecoder).join(); late final int exitCode; try { @@ -61,22 +127,70 @@ class CliResponderRunner { throw CliResponderExecutionException( responderId: responder.id, command: responder.command, - arguments: responder.args, + arguments: arguments, exitCode: exitCode, rawStdout: stdoutText, rawStderr: stderrText, ); } - final decoded = _decodeJson(stdoutText); - return ResponderResult.fromJson( - decoded, - responderId: responder.id, - rawStdout: stdoutText, - rawStderr: stderrText, + return _RawResponderOutput( + arguments: arguments, + stdout: stdoutText, + stderr: stderrText, ); } + Map? _tryDecodeJson(String stdout) { + try { + return _decodeJson(stdout); + } on FormatException { + return null; + } + } + + FormatException _decodeFormatException(String stdout) { + try { + _decodeJson(stdout); + return const FormatException('Responder output was not valid JSON.'); + } on FormatException catch (error) { + return error; + } + } + + bool _canRepairJsonOutput(CliResponderConfig responder, String stdout) { + if (stdout.trim().isEmpty) { + return false; + } + final command = responder.command.toLowerCase(); + final id = responder.id.toLowerCase(); + return id.contains('copilot') || command.contains('copilot'); + } + + String _buildJsonRepairPrompt(String originalPrompt, String invalidOutput) { + return ''' +The previous responder output was invalid because it was not a JSON object. + +Invalid output: +$invalidOutput + +Your task is to repair the response for the original request below. Do not +acknowledge this message, do not use tools, do not inspect files, and do not +include Markdown fences or prose. + +Return exactly one JSON object matching this schema: +{ + "decision": "reply" | "no_reply", + "mode": "planning" | "bug_verification", + "markdown": "markdown response, omitted for no_reply", + "summary": "short internal summary" +} + +Original request: +$originalPrompt +'''; + } + Map _decodeJson(String stdout) { final trimmed = stdout.trim(); if (trimmed.isEmpty) { @@ -103,6 +217,7 @@ class CliResponderRunner { Map? _decodeJsonlEvents(String stdout) { Map? lastEvent; + String? lastAssistantMessageContent; for (final line in const LineSplitter().convert(stdout)) { final trimmed = line.trim(); if (trimmed.isEmpty || !trimmed.startsWith('{')) { @@ -115,14 +230,27 @@ class CliResponderRunner { continue; } lastEvent = decoded.cast(); + final copilotMessageContent = _readCopilotAssistantMessageContent( + lastEvent, + ); + if (copilotMessageContent != null) { + lastAssistantMessageContent = copilotMessageContent; + final decodedContent = _decodeJsonObjectFromText( + copilotMessageContent, + ); + if (decodedContent != null) { + return decodedContent; + } + } 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(); + final decodedText = _decodeJsonObjectFromText(text); + if (decodedText != null) { + return decodedText; } } } on FormatException { @@ -136,9 +264,48 @@ class CliResponderRunner { return (jsonDecode(trimmed) as Map).cast(); } } + if (lastAssistantMessageContent != null) { + return _decodeJsonObjectFromText(lastAssistantMessageContent); + } return null; } + + String? _readCopilotAssistantMessageContent(Map event) { + if (event['type'] != 'assistant.message') { + return null; + } + final data = event['data']; + if (data is! Map) { + return null; + } + final content = data['content']; + return content is String ? content : null; + } + + Map? _decodeJsonObjectFromText(String text) { + final trimmed = text.trim(); + if (!trimmed.startsWith('{')) { + return null; + } + try { + return (jsonDecode(trimmed) as Map).cast(); + } on FormatException { + return null; + } + } +} + +class _RawResponderOutput { + _RawResponderOutput({ + required this.arguments, + required this.stdout, + required this.stderr, + }); + + final List arguments; + final String stdout; + final String stderr; } class CliResponderExecutionException implements Exception { @@ -166,6 +333,30 @@ class CliResponderExecutionException implements Exception { } } +class CliResponderOutputFormatException implements Exception { + CliResponderOutputFormatException({ + required this.responderId, + required this.command, + required this.arguments, + required this.error, + required this.rawStdout, + required this.rawStderr, + }); + + final String responderId; + final String command; + final List arguments; + final FormatException error; + final String rawStdout; + final String rawStderr; + + @override + String toString() { + return 'CliResponderOutputFormatException(responderId: $responderId, ' + 'command: $command, details: $error)'; + } +} + class ResponderResult { ResponderResult({ required this.responderId, diff --git a/lib/src/core/issue_assistant_app.dart b/lib/src/core/issue_assistant_app.dart index c99b7f3..0409fac 100644 --- a/lib/src/core/issue_assistant_app.dart +++ b/lib/src/core/issue_assistant_app.dart @@ -345,10 +345,12 @@ class IssueAssistantApp { lastError = error; final rawStdout = switch (error) { CliResponderExecutionException(:final rawStdout) => rawStdout, + CliResponderOutputFormatException(:final rawStdout) => rawStdout, _ => '', }; final rawStderr = switch (error) { CliResponderExecutionException(:final rawStderr) => rawStderr, + CliResponderOutputFormatException(:final rawStderr) => rawStderr, _ => error.toString(), }; _logResponderOutput( diff --git a/test/core/cli_responder_test.dart b/test/core/cli_responder_test.dart new file mode 100644 index 0000000..48a50c3 --- /dev/null +++ b/test/core/cli_responder_test.dart @@ -0,0 +1,476 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:code_work_spawner/code_work_spawner.dart'; +import 'package:code_work_spawner/src/core/cli_responder.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + /// ```gherkin + /// Feature: CLI responder execution + /// + /// As a user configuring external responder CLIs + /// I want responder arguments to receive the generated issue prompt + /// So that prompt-based tools such as Copilot CLI are invoked with valid arguments + /// ``` + group('CliResponderRunner.run', () { + /// ```gherkin + /// Scenario: Render prompt placeholders in responder arguments + /// Given a responder command whose arguments contain prompt and repo placeholders + /// When the CLI responder runner starts the external command + /// Then the launched command receives rendered argument values + /// And the responder output is decoded as a normal response + /// ``` + test('Render prompt placeholders in responder arguments', () async { + // Given a responder command whose arguments contain prompt and repo placeholders. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-cli-responder-', + ); + final scriptFile = File(p.join(sandbox.path, 'responder.dart')); + final argCaptureFile = File(p.join(sandbox.path, 'args.json')); + await scriptFile.writeAsString(''' +import 'dart:convert'; +import 'dart:io'; + +void main(List args) { + File(Platform.environment['ARG_CAPTURE']!).writeAsStringSync(jsonEncode(args)); + stdout.write(jsonEncode({ + 'decision': 'no_reply', + 'mode': 'planning', + 'summary': 'captured rendered args', + })); +} +'''); + final configFile = File(p.join(sandbox.path, 'CWS_conf.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + responders: + - id: prompt-cli + command: ${jsonEncode(Platform.resolvedExecutable)} + args: + - ${jsonEncode(scriptFile.path)} + - --prompt + - "{{prompt}}" + - --repo + - "{{repo}}" + env: + ARG_CAPTURE: ${jsonEncode(argCaptureFile.path)} + timeout: 1m +projects: + sample: + repo: owner/sample + path: ${jsonEncode(sandbox.path)} +'''); + final config = await AppConfig.load(configFile.path); + final project = config.projects['sample']!; + final thread = IssueThread( + issue: GitHubIssueSummary( + trackerProject: 'owner/sample', + repoSlug: project.repoSlug, + number: 7, + title: 'Prompt argument handling', + body: 'The prompt flag needs a value.', + state: 'open', + url: 'https://example.test/issues/7', + updatedAt: DateTime.utc(2026, 4, 14), + userLogin: 'reporter', + labels: const [], + ), + comments: const [], + ); + + // When the CLI responder runner starts the external command. + final result = await CliResponderRunner().run( + responder: project.issueAssistant.responders.single, + project: project, + thread: thread, + prompt: 'Respond to owner/sample issue #7', + workspacePath: sandbox.path, + ); + + // Then the launched command receives rendered argument values. + final capturedArgs = + (jsonDecode(await argCaptureFile.readAsString()) as List) + .cast(); + expect(capturedArgs, [ + '--prompt', + 'Respond to owner/sample issue #7', + '--repo', + 'owner/sample', + ]); + + // And the responder output is decoded as a normal response. + expect(result.decision, 'no_reply'); + expect(result.summary, 'captured rendered args'); + }); + + /// ```gherkin + /// Scenario: Render Copilot responder arguments for silent JSON output + /// Given a Copilot-style responder command with a prompt placeholder and silent output flag + /// When the CLI responder runner starts the external command + /// Then the launched command receives the prompt before permission and mode flags + /// And the launched command keeps custom instructions enabled + /// And the launched command avoids JSONL output mode + /// ``` + test('Render Copilot responder arguments for silent JSON output', () async { + // Given a Copilot-style responder command with a prompt placeholder and silent output flag. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-copilot-args-', + ); + final scriptFile = File(p.join(sandbox.path, 'responder.dart')); + final argCaptureFile = File(p.join(sandbox.path, 'args.json')); + final stdinCaptureFile = File(p.join(sandbox.path, 'stdin.txt')); + await scriptFile.writeAsString(''' +import 'dart:convert'; +import 'dart:io'; + +Future main(List args) async { + File(Platform.environment['ARG_CAPTURE']!).writeAsStringSync(jsonEncode(args)); + File(Platform.environment['STDIN_CAPTURE']!).writeAsStringSync( + await utf8.decoder.bind(stdin).join(), + ); + stdout.write(jsonEncode({ + 'decision': 'no_reply', + 'mode': 'planning', + 'summary': 'captured copilot args', + })); +} +'''); + final configFile = File(p.join(sandbox.path, 'CWS_conf.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + responders: + - id: copilot-cli + command: ${jsonEncode(Platform.resolvedExecutable)} + args: + - ${jsonEncode(scriptFile.path)} + - "-p" + - "" + - "--allow-all" + - "--autopilot" + - "--silent" + env: + ARG_CAPTURE: ${jsonEncode(argCaptureFile.path)} + STDIN_CAPTURE: ${jsonEncode(stdinCaptureFile.path)} + timeout: 1m +projects: + sample: + repo: owner/sample + path: ${jsonEncode(sandbox.path)} +'''); + final config = await AppConfig.load(configFile.path); + final project = config.projects['sample']!; + final thread = IssueThread( + issue: GitHubIssueSummary( + trackerProject: 'owner/sample', + repoSlug: project.repoSlug, + number: 9, + title: 'Copilot silent output', + body: 'The responder needs parseable stdout.', + state: 'open', + url: 'https://example.test/issues/9', + updatedAt: DateTime.utc(2026, 4, 14), + userLogin: 'reporter', + labels: const [], + ), + comments: const [], + ); + + // When the CLI responder runner starts the external command. + final result = await CliResponderRunner().run( + responder: project.issueAssistant.responders.single, + project: project, + thread: thread, + prompt: 'Return JSON only', + workspacePath: sandbox.path, + ); + + // Then the launched command receives an empty prompt argument before permission and mode flags. + final capturedArgs = + (jsonDecode(await argCaptureFile.readAsString()) as List) + .cast(); + expect(capturedArgs, [ + '-p', + '', + '--allow-all', + '--autopilot', + '--silent', + ]); + + // And the launched command keeps custom instructions enabled. + expect(capturedArgs, isNot(contains('--no-custom-instructions'))); + + // And the launched command avoids JSONL output mode. + expect(capturedArgs, isNot(contains('--output-format'))); + expect(await stdinCaptureFile.readAsString(), 'Return JSON only'); + expect(result.summary, 'captured copilot args'); + }); + + /// ```gherkin + /// Scenario: Repair invalid Copilot text output once + /// Given a Copilot-style responder command that first returns an acknowledgement instead of JSON + /// And the same responder can return strict JSON when given a repair prompt + /// When the CLI responder runner decodes the responder output + /// Then it retries the Copilot responder once with the repair prompt + /// And it returns the repaired JSON response + /// ``` + test('Repair invalid Copilot text output once', () async { + // Given a Copilot-style responder command that first returns an acknowledgement instead of JSON. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-copilot-repair-', + ); + final scriptFile = File(p.join(sandbox.path, 'responder.dart')); + final promptCaptureFile = File(p.join(sandbox.path, 'prompts.json')); + await scriptFile.writeAsString(''' +import 'dart:convert'; +import 'dart:io'; + +Future main(List args) async { + final prompt = await utf8.decoder.bind(stdin).join(); + final capture = File(Platform.environment['PROMPT_CAPTURE']!); + final prompts = capture.existsSync() + ? (jsonDecode(capture.readAsStringSync()) as List) + : []; + prompts.add(prompt); + capture.writeAsStringSync(jsonEncode(prompts)); + if (prompts.length == 1) { + stdout.write('Understood.'); + return; + } + stdout.write(jsonEncode({ + 'decision': 'no_reply', + 'mode': 'planning', + 'summary': 'repaired invalid copilot output', + })); +} +'''); + final configFile = File(p.join(sandbox.path, 'CWS_conf.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + responders: + - id: copilot-cli + command: ${jsonEncode(Platform.resolvedExecutable)} + args: + - ${jsonEncode(scriptFile.path)} + - "-p" + - "" + - "--allow-all" + - "--autopilot" + - "--silent" + env: + PROMPT_CAPTURE: ${jsonEncode(promptCaptureFile.path)} + timeout: 1m +projects: + sample: + repo: owner/sample + path: ${jsonEncode(sandbox.path)} +'''); + final config = await AppConfig.load(configFile.path); + final project = config.projects['sample']!; + final thread = IssueThread( + issue: GitHubIssueSummary( + trackerProject: 'owner/sample', + repoSlug: project.repoSlug, + number: 10, + title: 'Copilot acknowledgement repair', + body: 'The responder returned Understood.', + state: 'open', + url: 'https://example.test/issues/10', + updatedAt: DateTime.utc(2026, 4, 14), + userLogin: 'reporter', + labels: const [], + ), + comments: const [], + ); + + // And the same responder can return strict JSON when given a repair prompt. + + // When the CLI responder runner decodes the responder output. + final result = await CliResponderRunner().run( + responder: project.issueAssistant.responders.single, + project: project, + thread: thread, + prompt: 'Classify the issue thread', + workspacePath: sandbox.path, + ); + + // Then it retries the Copilot responder once with the repair prompt. + final capturedPrompts = + (jsonDecode(await promptCaptureFile.readAsString()) as List) + .cast(); + expect(capturedPrompts, hasLength(2)); + expect(capturedPrompts.last, contains('Invalid output:')); + expect(capturedPrompts.last, contains('Understood.')); + + // And it returns the repaired JSON response. + expect(result.decision, 'no_reply'); + expect(result.summary, 'repaired invalid copilot output'); + }); + + /// ```gherkin + /// Scenario: Tolerate malformed responder diagnostics + /// Given a responder command that writes malformed UTF-8 bytes to stderr + /// And the responder writes valid JSON to stdout + /// When the CLI responder runner reads the process output + /// Then it still decodes the JSON response + /// ``` + test('Tolerate malformed responder diagnostics', () async { + // Given a responder command that writes malformed UTF-8 bytes to stderr. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-malformed-stderr-', + ); + final scriptFile = File(p.join(sandbox.path, 'responder.dart')); + await scriptFile.writeAsString(''' +import 'dart:convert'; +import 'dart:io'; + +void main(List args) { + stderr.add([0xA1]); + stdout.write(jsonEncode({ + 'decision': 'no_reply', + 'mode': 'planning', + 'summary': 'ignored malformed stderr', + })); +} +'''); + final configFile = File(p.join(sandbox.path, 'CWS_conf.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + responders: + - id: malformed-diagnostics + command: ${jsonEncode(Platform.resolvedExecutable)} + args: + - ${jsonEncode(scriptFile.path)} + timeout: 1m +projects: + sample: + repo: owner/sample + path: ${jsonEncode(sandbox.path)} +'''); + final config = await AppConfig.load(configFile.path); + final project = config.projects['sample']!; + final thread = IssueThread( + issue: GitHubIssueSummary( + trackerProject: 'owner/sample', + repoSlug: project.repoSlug, + number: 11, + title: 'Malformed diagnostics', + body: 'The responder writes non-UTF-8 diagnostics.', + state: 'open', + url: 'https://example.test/issues/11', + updatedAt: DateTime.utc(2026, 4, 14), + userLogin: 'reporter', + labels: const [], + ), + comments: const [], + ); + + // And the responder writes valid JSON to stdout. + + // When the CLI responder runner reads the process output. + final result = await CliResponderRunner().run( + responder: project.issueAssistant.responders.single, + project: project, + thread: thread, + prompt: 'Return JSON only', + workspacePath: sandbox.path, + ); + + // Then it still decodes the JSON response. + expect(result.decision, 'no_reply'); + expect(result.summary, 'ignored malformed stderr'); + }); + + /// ```gherkin + /// Scenario: Decode Copilot CLI JSONL assistant messages + /// Given a responder command that emits Copilot CLI JSONL events + /// When the CLI responder runner reads the assistant.message event + /// Then it decodes the responder JSON from the event data content + /// ``` + test('Decode Copilot CLI JSONL assistant messages', () async { + // Given a responder command that emits Copilot CLI JSONL events. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-copilot-jsonl-', + ); + final scriptFile = File(p.join(sandbox.path, 'responder.dart')); + await scriptFile.writeAsString(''' +import 'dart:convert'; +import 'dart:io'; + +void main(List args) { + stdout.writeln(jsonEncode({ + 'type': 'session.mcp_servers_loaded', + 'data': {'servers': []}, + })); + stdout.writeln(jsonEncode({ + 'type': 'assistant.message', + 'data': { + 'content': jsonEncode({ + 'decision': 'reply', + 'mode': 'planning', + 'markdown': 'Use the existing boundary.', + 'summary': 'decoded copilot jsonl', + }), + }, + })); + stdout.writeln(jsonEncode({ + 'type': 'result', + 'exitCode': 0, + })); +} +'''); + final configFile = File(p.join(sandbox.path, 'CWS_conf.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + responders: + - id: copilot-cli + command: ${jsonEncode(Platform.resolvedExecutable)} + args: + - ${jsonEncode(scriptFile.path)} + timeout: 1m +projects: + sample: + repo: owner/sample + path: ${jsonEncode(sandbox.path)} +'''); + final config = await AppConfig.load(configFile.path); + final project = config.projects['sample']!; + final thread = IssueThread( + issue: GitHubIssueSummary( + trackerProject: 'owner/sample', + repoSlug: project.repoSlug, + number: 8, + title: 'Copilot JSONL handling', + body: 'Copilot emits assistant.message events.', + state: 'open', + url: 'https://example.test/issues/8', + updatedAt: DateTime.utc(2026, 4, 14), + userLogin: 'reporter', + labels: const [], + ), + comments: const [], + ); + + // When the CLI responder runner reads the assistant.message event. + final result = await CliResponderRunner().run( + responder: project.issueAssistant.responders.single, + project: project, + thread: thread, + prompt: 'Respond with JSON', + workspacePath: sandbox.path, + ); + + // Then it decodes the responder JSON from the event data content. + expect(result.decision, 'reply'); + expect(result.mode, 'planning'); + expect(result.markdown, 'Use the existing boundary.'); + expect(result.summary, 'decoded copilot jsonl'); + }); + }); +}