feat: patial copilot support
This commit is contained in:
parent
035e44998f
commit
ed94fde9e7
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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-jsonl-',
|
||||
);
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue