Merge branch 'feat/copilot'

This commit is contained in:
insleker 2026-04-14 14:46:33 +08:00
commit cbd0305a39
11 changed files with 817 additions and 45 deletions

View File

@ -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 <LOGIN>`.

View File

@ -13,7 +13,7 @@ defaults:
responders:
- id: codex
command: codex
args: ["exec", "--json"]
args: ["exec"]
timeout: 10m
projects:

View File

@ -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.

View File

@ -13,7 +13,7 @@ defaults:
responders:
- id: codex
command: codex
args: ["exec", "--json"]
args: ["exec"]
timeout: 10m
projects:

View File

@ -13,7 +13,7 @@ defaults:
responders:
- id: codex
command: codex
args: ["exec", "--json"]
args: ["exec"]
timeout: 10m
projects:

View File

@ -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}

View File

@ -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 <String>[]),
args: _validateArgs(id, document.args ?? const <String>[]),
env: document.env ?? const <String, String>{},
stdinTemplate: document.stdinTemplate ?? '{{prompt}}',
timeout: _parseDuration(document.timeout ?? '10m'),
);
}
static List<String> _normalizeArgs(Iterable<String> args) {
return args
.map((arg) => arg == '--json-output' ? '--json' : arg)
.toList(growable: false);
static List<String> _validateArgs(String id, Iterable<String> 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

View File

@ -14,7 +14,63 @@ class CliResponderRunner {
required String prompt,
required String workspacePath,
}) async {
final context = <String, String>{
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<String, String> _buildContext({
required ProjectConfig project,
required IssueThread thread,
required String prompt,
required String workspacePath,
}) {
return <String, String>{
'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<String, String> 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: <String, String>{
...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<String, dynamic>? _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<String, dynamic> _decodeJson(String stdout) {
final trimmed = stdout.trim();
if (trimmed.isEmpty) {
@ -103,6 +217,7 @@ class CliResponderRunner {
Map<String, dynamic>? _decodeJsonlEvents(String stdout) {
Map<String, dynamic>? 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<String, dynamic>();
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<String, dynamic>();
final decodedText = _decodeJsonObjectFromText(text);
if (decodedText != null) {
return decodedText;
}
}
} on FormatException {
@ -136,9 +264,48 @@ class CliResponderRunner {
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
}
}
if (lastAssistantMessageContent != null) {
return _decodeJsonObjectFromText(lastAssistantMessageContent);
}
return null;
}
String? _readCopilotAssistantMessageContent(Map<String, dynamic> 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<String, dynamic>? _decodeJsonObjectFromText(String text) {
final trimmed = text.trim();
if (!trimmed.startsWith('{')) {
return null;
}
try {
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
} on FormatException {
return null;
}
}
}
class _RawResponderOutput {
_RawResponderOutput({
required this.arguments,
required this.stdout,
required this.stderr,
});
final List<String> 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<String> 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,

View File

@ -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(

View File

@ -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<FormatException>().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<FormatException>().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')

View File

@ -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<String> 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<String>();
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<void> main(List<String> 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<String>();
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<void> main(List<String> 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)
: <Object?>[];
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<String>();
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<String> 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<String> 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');
});
});
}