fix: issue responder context and mention replies

This commit is contained in:
insleker 2026-04-04 23:03:38 +08:00
parent 42b89719fd
commit 558db6991c
7 changed files with 348 additions and 19 deletions

View File

@ -74,7 +74,7 @@ AO-style `dataDir`, `worktreeDir`, and core `projects` fields, and uses
## Run
```bash
dart run build_runner build
dart run build_runner build --delete-conflicting-outputs
```
Single poll cycle:

10
build.yaml Normal file
View File

@ -0,0 +1,10 @@
targets:
$default:
# sources:
builders:
drift_dev:
generate_for:
- lib/src/database.dart
# json_serializable:
# generate_for:
# - lib/**/model/*.dart

View File

@ -9,7 +9,7 @@ defaults:
issueAssistant:
enabled: true
pollInterval: 5m
mentionTriggers: ["@codex"]
mentionTriggers: ["@codex", "@helper"]
responders:
# Replace this with a responder available on your machine.
- id: codex

View File

@ -61,11 +61,13 @@ class CliResponderRunner {
final stdoutText = await stdoutFuture;
final stderrText = await stderrFuture;
if (exitCode != 0) {
throw ProcessException(
responder.command,
responder.args,
stderrText.isEmpty ? stdoutText : stderrText,
exitCode,
throw CliResponderExecutionException(
responderId: responder.id,
command: responder.command,
arguments: responder.args,
exitCode: exitCode,
rawStdout: stdoutText,
rawStderr: stderrText,
);
}
@ -142,6 +144,31 @@ class CliResponderRunner {
}
}
class CliResponderExecutionException implements Exception {
CliResponderExecutionException({
required this.responderId,
required this.command,
required this.arguments,
required this.exitCode,
required this.rawStdout,
required this.rawStderr,
});
final String responderId;
final String command;
final List<String> arguments;
final int exitCode;
final String rawStdout;
final String rawStderr;
@override
String toString() {
final details = rawStderr.isEmpty ? rawStdout : rawStderr;
return 'CliResponderExecutionException(responderId: $responderId, '
'command: $command, exitCode: $exitCode, details: $details)';
}
}
class ResponderResult {
ResponderResult({
required this.responderId,
@ -163,6 +190,23 @@ class ResponderResult {
bool get shouldReply => decision == 'reply';
ResponderResult forceReply() {
final fallbackMarkdown = markdown.isNotEmpty
? markdown
: summary.isNotEmpty
? summary
: 'I reviewed the latest mention but could not produce a detailed response.';
return ResponderResult(
responderId: responderId,
decision: 'reply',
mode: mode,
markdown: fallbackMarkdown,
summary: summary,
rawStdout: rawStdout,
rawStderr: rawStderr,
);
}
factory ResponderResult.fromJson(
Map<String, dynamic> json, {
required String responderId,

View File

@ -315,8 +315,6 @@ class IssueAssistantConfig {
static const String _defaultPrompt = '''
You are a GitHub issue thread assistant.
You will receive repository metadata, the current issue thread as JSON, and the latest trigger context.
Decide whether to reply. Rules:
- If the latest trigger explicitly mentions the assistant, always return "reply".
- If the discussion already has a good enough answer, return "no_reply".
@ -332,6 +330,23 @@ JSON schema:
"markdown": "markdown response, omitted for no_reply",
"summary": "short internal summary"
}
Repository metadata:
- repo: {{repo}}
- project_key: {{project_key}}
- project_path: {{project_path}}
- issue_number: {{issue_number}}
- issue_title: {{issue_title}}
- issue_url: {{issue_url}}
- mention_triggers: {{mention_triggers}}
- trigger: {{trigger}}
- capabilities: {{capabilities}}
Issue body:
{{issue_body}}
Current issue thread JSON:
{{thread_json}}
''';
}

View File

@ -197,9 +197,9 @@ class IssueAssistantApp {
for (final responder in project.issueAssistant.responders) {
final needsWorktree = _looksLikeBugVerification(thread);
workspacePath = needsWorktree
? await workspaceManager.createEphemeralWorktree(project.path)
: project.path;
workspacePath = await workspaceManager.createEphemeralWorktree(
project.path,
);
_log(
'run responder=${responder.id} issue=${project.key}#${issue.number} '
'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
@ -225,15 +225,43 @@ class IssueAssistantApp {
stdout: result.rawStdout,
stderr: result.rawStderr,
);
_logResponderOutput(
responderId: responder.id,
issueKey: '${project.key}#${issue.number}',
stdout: result.rawStdout,
stderr: result.rawStderr,
);
_log(
'responder=${responder.id}b issue=${project.key}#${issue.number}, '
'responder=${responder.id} issue=${project.key}#${issue.number}, '
'title="${thread.issue.title}", '
'decision=${result.decision}, mode=${result.mode}',
);
selectedResult = result;
selectedResult = forceReply && !result.shouldReply
? result.forceReply()
: result;
if (forceReply && !result.shouldReply) {
_log(
'responder=${responder.id} issue=${project.key}#${issue.number} '
'override=force_reply original_decision=${result.decision}',
);
}
break;
} catch (error) {
lastError = error;
final rawStdout = switch (error) {
CliResponderExecutionException(:final rawStdout) => rawStdout,
_ => '',
};
final rawStderr = switch (error) {
CliResponderExecutionException(:final rawStderr) => rawStderr,
_ => error.toString(),
};
_logResponderOutput(
responderId: responder.id,
issueKey: '${project.key}#${issue.number}',
stdout: rawStdout,
stderr: rawStderr,
);
_log(
'responder=${responder.id} issue=${project.key}#${issue.number} '
'failed error=$error',
@ -245,11 +273,11 @@ class IssueAssistantApp {
decision: 'no_reply',
success: false,
workspacePath: workspacePath,
stdout: '',
stderr: error.toString(),
stdout: rawStdout,
stderr: rawStderr,
);
} finally {
if (workspacePath != project.path) {
if (workspacePath != null) {
await workspaceManager.disposeEphemeralWorktree(workspacePath);
}
}
@ -393,4 +421,18 @@ class IssueAssistantApp {
void _logError(String message) {
_logger.severe(message);
}
void _logResponderOutput({
required String responderId,
required String issueKey,
required String stdout,
required String stderr,
}) {
if (stdout.isNotEmpty) {
_log('responder=$responderId issue=$issueKey stream=stdout\n$stdout');
}
if (stderr.isNotEmpty) {
_log('responder=$responderId issue=$issueKey stream=stderr\n$stderr');
}
}
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
@ -424,6 +425,219 @@ projects:
},
);
/// ```gherkin
/// Scenario: Log raw responder output for debugging
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing an explicit assistant mention
/// And a responder that writes stderr diagnostics alongside a no_reply JSON payload
/// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle while app logs are captured
/// Then the application logs include the responder stdout payload
/// And the application logs include the responder stderr diagnostics
/// ```
test('Log raw responder output for debugging', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp('cws-log-output-');
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await _initGitRepo(repoDir);
// And GitHub issue data containing an explicit assistant mention.
final ghScript = File(p.join(sandbox.path, 'gh'));
final issueData = [
{
'number': 21,
'title': 'Need responder diagnostics',
'body': 'Inspect the responder output.',
'state': 'open',
'html_url': 'https://example.test/issues/21',
'updated_at': '2026-04-04T14:10:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 210,
'body': '@helper please explain why you skipped this',
'html_url': 'https://example.test/issues/21#issuecomment-210',
'created_at': '2026-04-04T14:10:00Z',
'updated_at': '2026-04-04T14:10:00Z',
'user': {'login': 'reporter'},
},
];
await _writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {21: commentsData},
postCommentIssueNumber: 21,
);
// And a responder that writes stderr diagnostics alongside a no_reply JSON payload.
final responderScript = File(p.join(sandbox.path, 'responder-debug.sh'));
await _writeResponderScript(responderScript, {
'decision': 'no_reply',
'mode': 'planning',
'summary': 'diagnostic skip',
}, stderr: 'codex diagnostic trace');
// And an orchestrator-style config pointing to the fake repo and responder.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
pollInterval: 5m
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
projects:
sample:
repo: owner/sample
path: ${repoDir.path}
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path,
);
final logMessages = <String>[];
final previousLevel = Logger.root.level;
Logger.root.level = Level.INFO;
final subscription = Logger.root.onRecord.listen((record) {
logMessages.add(record.message);
});
// When the app processes one polling cycle while app logs are captured.
await app.runOnce();
await subscription.cancel();
Logger.root.level = previousLevel;
await app.close();
// Then the application logs include the responder stdout payload.
expect(logMessages.join('\n'), contains('"decision":"no_reply"'));
// And the application logs include the responder stderr diagnostics.
expect(logMessages.join('\n'), contains('codex diagnostic trace'));
});
/// ```gherkin
/// Scenario: Run mention replies from an ephemeral worktree with issue context
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing an explicit assistant mention
/// And a responder that records its working directory and stdin before returning no_reply
/// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle
/// Then the responder runs from an ephemeral worktree instead of the source checkout
/// And the responder stdin includes the latest issue comment text
/// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant
/// ```
test('Run mention replies from an ephemeral worktree with issue context', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-mention-worktree-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await _initGitRepo(repoDir);
// And GitHub issue data containing an explicit assistant mention.
final ghScript = File(p.join(sandbox.path, 'gh'));
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
final issueData = [
{
'number': 24,
'title': 'Need your thoughts',
'body': 'Please review the CLI workflow.',
'state': 'open',
'html_url': 'https://example.test/issues/24',
'updated_at': '2026-04-04T15:30:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 240,
'body': '@helper give me your though about this CLI flow',
'html_url': 'https://example.test/issues/24#issuecomment-240',
'created_at': '2026-04-04T15:30:00Z',
'updated_at': '2026-04-04T15:30:00Z',
'user': {'login': 'reporter'},
},
];
await _writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {24: commentsData},
ghLog: ghLog,
postCommentIssueNumber: 24,
);
// And a responder that records its working directory and stdin before returning no_reply.
final cwdCapture = File(p.join(sandbox.path, 'responder.cwd'));
final stdinCapture = File(p.join(sandbox.path, 'responder.stdin'));
final responderScript = File(
p.join(sandbox.path, 'responder-capture.sh'),
);
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
pwd > "${cwdCapture.path}"
cat > "${stdinCapture.path}"
cat <<'JSON'
{"decision":"no_reply","mode":"planning","summary":"captured mention context"}
JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
// And an orchestrator-style config pointing to the fake repo and responder.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
pollInterval: 5m
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
projects:
sample:
repo: owner/sample
path: ${repoDir.path}
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path,
);
// When the app processes one polling cycle.
await app.runOnce();
await app.close();
// Then the responder runs from an ephemeral worktree instead of the source checkout.
final capturedCwd = await cwdCapture.readAsString();
expect(p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path)));
// And the responder stdin includes the latest issue comment text.
final capturedStdin = await stdinCapture.readAsString();
expect(
capturedStdin,
contains('@helper give me your though about this CLI flow'),
);
// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant.
final logLines = await ghLog.readAsLines();
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/24/comments'),
)
.length,
2,
);
});
/// ```gherkin
/// Scenario: Accept codex style jsonl responder output
/// Given a temporary project checkout that can be used as the local repo path
@ -671,14 +885,18 @@ Future<void> _writeFakeGhScript({
Future<void> _writeResponderScript(
File responderScript,
Map<String, Object?> response,
) async {
Map<String, Object?> response, {
String stderr = '',
int exitCode = 0,
}) async {
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
cat >/dev/null
${stderr.isEmpty ? '' : "printf '%s\\n' ${jsonEncode(stderr)} >&2"}
cat <<'JSON'
${jsonEncode(response)}
JSON
exit $exitCode
''');
await Process.run('chmod', ['+x', responderScript.path]);
}