diff --git a/examples/README.md b/examples/README.md index ba3c370..ed30bb2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -80,7 +80,17 @@ Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub, This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for bug-verification worktrees. +## responder CLI mode + +Configure responders in plain-text mode. Do not add JSON/output-format flags in +`responders[].args` (for example `--json`, `--json-output`, `--output-format`, +or `-f json`), because `code_work_spawner` owns response-format handling. + +Prompts are passed through stdin via `stdinTemplate` (default: `{{prompt}}`). +In most cases prompt flags are not needed in `responders[].args`, because the +real prompt already comes from stdin. Only add a prompt flag if a specific CLI +fails without it. + ## tea + windows The `tea` CLI has known severe issues on Windows due to lipgloss/v2 query console color. We had bypass it by read config file of `tea` directly, but only token login is supported. And need to config a default login through `tea login default `. - diff --git a/examples/simple-gitea.yaml b/examples/simple-gitea.yaml index 217812b..c43d0b5 100644 --- a/examples/simple-gitea.yaml +++ b/examples/simple-gitea.yaml @@ -13,7 +13,7 @@ defaults: responders: - id: codex command: codex - args: ["exec", "--json"] + args: ["exec"] timeout: 10m projects: diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index 6489918..c05116c 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -25,15 +25,16 @@ defaults: # Replace this with a responder available on your machine. - id: codex command: codex - args: ["-s", "danger-full-access", "exec", "--json"] + args: ["-s", "danger-full-access", "exec"] timeout: 10m # - id: opencode # command: opencode - # args: ["run", "-f", "json"] + # args: ["run"] # timeout: 10m # - id: copilot-cli # command: copilot - # args: ["--allow-all", "--autopilot", "-p", "--output-format", "json"] + # # Prompt text is sent through stdin, so prompt flags are usually unnecessary. + # args: ["--allow-all", "--autopilot", "--silent"] # timeout: 10m # notifications: # # Optional Discord notifications for assistant activity. diff --git a/examples/simple-gitlab.yaml b/examples/simple-gitlab.yaml index 8968b9d..9d95d7f 100644 --- a/examples/simple-gitlab.yaml +++ b/examples/simple-gitlab.yaml @@ -13,7 +13,7 @@ defaults: responders: - id: codex command: codex - args: ["exec", "--json"] + args: ["exec"] timeout: 10m projects: diff --git a/examples/simple-openproject.yaml b/examples/simple-openproject.yaml index d4a50b9..db55379 100644 --- a/examples/simple-openproject.yaml +++ b/examples/simple-openproject.yaml @@ -13,7 +13,7 @@ defaults: responders: - id: codex command: codex - args: ["exec", "--json"] + args: ["exec"] timeout: 10m projects: 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..0e15c8b 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 { @@ -489,17 +494,24 @@ class CliResponderConfig { return CliResponderConfig( id: id, command: command, - args: _normalizeArgs(document.args ?? const []), + args: _validateArgs(id, document.args ?? const []), env: document.env ?? const {}, stdinTemplate: document.stdinTemplate ?? '{{prompt}}', timeout: _parseDuration(document.timeout ?? '10m'), ); } - static List _normalizeArgs(Iterable args) { - return args - .map((arg) => arg == '--json-output' ? '--json' : arg) - .toList(growable: false); + static List _validateArgs(String id, Iterable args) { + final normalized = args.toList(growable: false); + for (final arg in normalized) { + if (arg.toLowerCase().contains('json')) { + throw FormatException( + 'Responder "$id" has forbidden arg "$arg": ' + 'JSON/output-format args must not be configured in responders.args.', + ); + } + } + return normalized; } } @@ -1077,15 +1089,15 @@ ${_commentBlock(defaultIssueAssistantPrompt, indent: ' # ')} # responders: # - id: codex # command: codex - # args: ["exec", "--json"] + # args: ["exec"] # timeout: 10m # - id: opencode # command: opencode - # args: ["run", "-f", "json"] + # args: ["run"] # timeout: 10m # - id: copilot-cli # command: copilot - # args: ["--allow-all", "--autopilot", "-p", "--output-format", "json"] + # args: ["--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/config/app_config_test.dart b/test/config/app_config_test.dart index a2a71a6..afc531a 100644 --- a/test/config/app_config_test.dart +++ b/test/config/app_config_test.dart @@ -241,13 +241,13 @@ projects: }); /// ```gherkin - /// Scenario: Normalize legacy codex json flag - /// Given an orchestrator config that still uses the legacy codex --json-output flag + /// Scenario: Reject responder args that contain json output flags + /// Given an orchestrator config that configures a responder json output argument /// When loading the app config from disk - /// Then the responder args are normalized to the current codex --json flag + /// Then loading fails with a format exception about forbidden args /// ``` - test('Normalize legacy codex json flag', () async { - // Given an orchestrator config that still uses the legacy codex --json-output flag. + test('Reject responder args that contain json output flags', () async { + // Given an orchestrator config that configures a responder json output argument. final tempDir = await Directory.systemTemp.createTemp('cws-codex-args-'); final configFile = File(p.join(tempDir.path, 'CWS_conf.yaml')); await configFile.writeAsString(''' @@ -263,13 +263,97 @@ projects: path: ${tempDir.path} '''); + // When loading the app config from disk. + final future = AppConfig.load(configFile.path); + + // Then loading fails with a format exception about forbidden args. + await expectLater( + future, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('forbidden arg "--json-output"'), + ), + ), + ); + }); + + /// ```gherkin + /// Scenario: Reject responder args with mixed-case json tokens + /// Given an orchestrator config that configures a responder argument containing Json in mixed case + /// When loading the app config from disk + /// Then loading fails with a format exception about forbidden args + /// ``` + test('Reject responder args with mixed-case json tokens', () async { + // Given an orchestrator config that configures a responder argument containing Json in mixed case. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-codex-args-case-', + ); + final configFile = File(p.join(tempDir.path, 'CWS_conf.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + responders: + - id: codex + command: codex + args: ["exec", "--JsonOutput"] +projects: + sample: + repo: owner/sample + path: ${tempDir.path} +'''); + + // When loading the app config from disk. + final future = AppConfig.load(configFile.path); + + // Then loading fails with a format exception about forbidden args. + await expectLater( + future, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('forbidden arg "--JsonOutput"'), + ), + ), + ); + }); + + /// ```gherkin + /// Scenario: Keep non-format responder args + /// Given an orchestrator config that only sets non-format responder args + /// When loading the app config from disk + /// Then those args remain available to the responder + /// ``` + test('Keep non-format responder args', () async { + // Given an orchestrator config that only sets non-format responder args. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-codex-args-keep-', + ); + final configFile = File(p.join(tempDir.path, 'CWS_conf.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + responders: + - id: codex + command: codex + args: ["-s", "danger-full-access", "exec", "--allow-all"] +projects: + sample: + repo: owner/sample + path: ${tempDir.path} +'''); + // When loading the app config from disk. final config = await AppConfig.load(configFile.path); - // Then the responder args are normalized to the current codex --json flag. + // Then those args remain available to the responder. expect(config.projects['sample']!.issueAssistant.responders.single.args, [ + '-s', + 'danger-full-access', 'exec', - '--json', + '--allow-all', ]); }); @@ -1223,10 +1307,7 @@ projects: .replaceFirst(' # responders:', ' responders:') .replaceFirst(' # - id: codex', ' - id: codex') .replaceFirst(' # command: codex', ' command: codex') - .replaceFirst( - ' # args: ["exec", "--json"]', - ' args: ["exec", "--json"]', - ) + .replaceFirst(' # args: ["exec"]', ' args: ["exec"]') .replaceFirst(' # timeout: 10m', ' timeout: 10m') .replaceFirst(' # issueAssistant:', ' issueAssistant:') .replaceFirst(' # enabled: true', ' enabled: true') diff --git a/test/core/cli_responder_test.dart b/test/core/cli_responder_test.dart new file mode 100644 index 0000000..e7ea075 --- /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-events-', + ); + 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'); + }); + }); +}