forked from bkinnightskytw/code_work_spawner
feat: patial copilot support
This commit is contained in:
parent
035e44998f
commit
ed94fde9e7
|
|
@ -33,7 +33,7 @@ defaults:
|
||||||
# timeout: 10m
|
# timeout: 10m
|
||||||
# - id: copilot-cli
|
# - id: copilot-cli
|
||||||
# command: copilot
|
# command: copilot
|
||||||
# args: ["--allow-all", "--autopilot", "-p", "--output-format", "json"]
|
# args: ["-p", "", "--allow-all", "--autopilot", "--silent"]
|
||||||
# timeout: 10m
|
# timeout: 10m
|
||||||
# notifications:
|
# notifications:
|
||||||
# # Optional Discord notifications for assistant activity.
|
# # Optional Discord notifications for assistant activity.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ pre-push:
|
||||||
post-checkout:
|
post-checkout:
|
||||||
commands:
|
commands:
|
||||||
private-skills-sync:
|
private-skills-sync:
|
||||||
run: uvx --from git+https://github.com/existedinnettw/inkr-harness-tools.git inkr-skill-sync .local .agents --recursive
|
run: uvx --from git+https://github.com/existedinnettw/inkr-harness-tools.git inkr-skill-sync .local .agents --recursive || true
|
||||||
continue: true #ignore fail
|
|
||||||
pub-get-on-pubspec-change:
|
pub-get-on-pubspec-change:
|
||||||
run: dart tool/pub_get_on_pubspec_change.dart {1} {2} {3}
|
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 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.
|
- 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.
|
- Always return strict JSON only.
|
||||||
|
- Do not acknowledge these instructions or include any text outside the JSON object.
|
||||||
|
|
||||||
JSON schema:
|
JSON schema:
|
||||||
{
|
{
|
||||||
|
|
@ -59,6 +60,10 @@ Issue body:
|
||||||
|
|
||||||
Current issue thread JSON:
|
Current issue thread JSON:
|
||||||
{{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 {
|
class AppConfig {
|
||||||
|
|
@ -1085,7 +1090,7 @@ ${_commentBlock(defaultIssueAssistantPrompt, indent: ' # ')}
|
||||||
# timeout: 10m
|
# timeout: 10m
|
||||||
# - id: copilot-cli
|
# - id: copilot-cli
|
||||||
# command: copilot
|
# command: copilot
|
||||||
# args: ["--allow-all", "--autopilot", "-p", "--output-format", "json"]
|
# args: ["-p", "", "--allow-all", "--autopilot", "--silent"]
|
||||||
# timeout: 10m
|
# timeout: 10m
|
||||||
# notifications:
|
# notifications:
|
||||||
# - type: discordWebhook
|
# - type: discordWebhook
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,63 @@ class CliResponderRunner {
|
||||||
required String prompt,
|
required String prompt,
|
||||||
required String workspacePath,
|
required String workspacePath,
|
||||||
}) async {
|
}) 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,
|
'prompt': prompt,
|
||||||
'repo': project.repo,
|
'repo': project.repo,
|
||||||
'project_key': project.key,
|
'project_key': project.key,
|
||||||
|
|
@ -23,27 +79,37 @@ class CliResponderRunner {
|
||||||
'issue_number': thread.issue.number.toString(),
|
'issue_number': thread.issue.number.toString(),
|
||||||
'thread_json': encodePrettyJson(thread.toJson()),
|
'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 stdinPayload = renderTemplate(responder.stdinTemplate, context);
|
||||||
|
final arguments = responder.args
|
||||||
|
.map((argument) => renderTemplate(argument, context))
|
||||||
|
.toList(growable: false);
|
||||||
final process = await startExternalCommand(
|
final process = await startExternalCommand(
|
||||||
responder.command,
|
responder.command,
|
||||||
responder.args,
|
arguments,
|
||||||
workingDirectory: workspacePath,
|
workingDirectory: workspacePath,
|
||||||
environment: <String, String>{
|
environment: <String, String>{
|
||||||
...responder.env,
|
...responder.env,
|
||||||
'CWS_PROJECT_KEY': project.key,
|
'CWS_PROJECT_KEY': context['project_key'] ?? '',
|
||||||
'CWS_REPO': project.repo,
|
'CWS_REPO': context['repo'] ?? '',
|
||||||
'CWS_PROJECT_PATH': project.path,
|
'CWS_PROJECT_PATH': context['project_path'] ?? '',
|
||||||
'CWS_WORKSPACE_PATH': workspacePath,
|
'CWS_WORKSPACE_PATH': workspacePath,
|
||||||
'CWS_ISSUE_NUMBER': thread.issue.number.toString(),
|
'CWS_ISSUE_NUMBER': context['issue_number'] ?? '',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
process.stdin.write(stdinPayload);
|
process.stdin.write(stdinPayload);
|
||||||
await process.stdin.close();
|
await process.stdin.close();
|
||||||
|
|
||||||
final stdoutFuture = process.stdout.transform(utf8.decoder).join();
|
const outputDecoder = Utf8Decoder(allowMalformed: true);
|
||||||
final stderrFuture = process.stderr.transform(utf8.decoder).join();
|
final stdoutFuture = process.stdout.transform(outputDecoder).join();
|
||||||
|
final stderrFuture = process.stderr.transform(outputDecoder).join();
|
||||||
|
|
||||||
late final int exitCode;
|
late final int exitCode;
|
||||||
try {
|
try {
|
||||||
|
|
@ -61,22 +127,70 @@ class CliResponderRunner {
|
||||||
throw CliResponderExecutionException(
|
throw CliResponderExecutionException(
|
||||||
responderId: responder.id,
|
responderId: responder.id,
|
||||||
command: responder.command,
|
command: responder.command,
|
||||||
arguments: responder.args,
|
arguments: arguments,
|
||||||
exitCode: exitCode,
|
exitCode: exitCode,
|
||||||
rawStdout: stdoutText,
|
rawStdout: stdoutText,
|
||||||
rawStderr: stderrText,
|
rawStderr: stderrText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final decoded = _decodeJson(stdoutText);
|
return _RawResponderOutput(
|
||||||
return ResponderResult.fromJson(
|
arguments: arguments,
|
||||||
decoded,
|
stdout: stdoutText,
|
||||||
responderId: responder.id,
|
stderr: stderrText,
|
||||||
rawStdout: stdoutText,
|
|
||||||
rawStderr: 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) {
|
Map<String, dynamic> _decodeJson(String stdout) {
|
||||||
final trimmed = stdout.trim();
|
final trimmed = stdout.trim();
|
||||||
if (trimmed.isEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
|
|
@ -103,6 +217,7 @@ class CliResponderRunner {
|
||||||
|
|
||||||
Map<String, dynamic>? _decodeJsonlEvents(String stdout) {
|
Map<String, dynamic>? _decodeJsonlEvents(String stdout) {
|
||||||
Map<String, dynamic>? lastEvent;
|
Map<String, dynamic>? lastEvent;
|
||||||
|
String? lastAssistantMessageContent;
|
||||||
for (final line in const LineSplitter().convert(stdout)) {
|
for (final line in const LineSplitter().convert(stdout)) {
|
||||||
final trimmed = line.trim();
|
final trimmed = line.trim();
|
||||||
if (trimmed.isEmpty || !trimmed.startsWith('{')) {
|
if (trimmed.isEmpty || !trimmed.startsWith('{')) {
|
||||||
|
|
@ -115,14 +230,27 @@ class CliResponderRunner {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
lastEvent = decoded.cast<String, dynamic>();
|
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'];
|
final item = lastEvent['item'];
|
||||||
if (item is Map &&
|
if (item is Map &&
|
||||||
item['type']?.toString() == 'agent_message' &&
|
item['type']?.toString() == 'agent_message' &&
|
||||||
item['text'] is String) {
|
item['text'] is String) {
|
||||||
final text = (item['text'] as String).trim();
|
final text = (item['text'] as String).trim();
|
||||||
if (text.startsWith('{')) {
|
final decodedText = _decodeJsonObjectFromText(text);
|
||||||
return (jsonDecode(text) as Map).cast<String, dynamic>();
|
if (decodedText != null) {
|
||||||
|
return decodedText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on FormatException {
|
} on FormatException {
|
||||||
|
|
@ -136,9 +264,48 @@ class CliResponderRunner {
|
||||||
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
|
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (lastAssistantMessageContent != null) {
|
||||||
|
return _decodeJsonObjectFromText(lastAssistantMessageContent);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
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 {
|
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 {
|
class ResponderResult {
|
||||||
ResponderResult({
|
ResponderResult({
|
||||||
required this.responderId,
|
required this.responderId,
|
||||||
|
|
|
||||||
|
|
@ -345,10 +345,12 @@ class IssueAssistantApp {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
final rawStdout = switch (error) {
|
final rawStdout = switch (error) {
|
||||||
CliResponderExecutionException(:final rawStdout) => rawStdout,
|
CliResponderExecutionException(:final rawStdout) => rawStdout,
|
||||||
|
CliResponderOutputFormatException(:final rawStdout) => rawStdout,
|
||||||
_ => '',
|
_ => '',
|
||||||
};
|
};
|
||||||
final rawStderr = switch (error) {
|
final rawStderr = switch (error) {
|
||||||
CliResponderExecutionException(:final rawStderr) => rawStderr,
|
CliResponderExecutionException(:final rawStderr) => rawStderr,
|
||||||
|
CliResponderOutputFormatException(:final rawStderr) => rawStderr,
|
||||||
_ => error.toString(),
|
_ => error.toString(),
|
||||||
};
|
};
|
||||||
_logResponderOutput(
|
_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