fix: stop forcing mention replies (#41)
Stop overriding responder no_reply decisions for mention-triggered comments so automation mentions do not loop indefinitely. Update the default prompt to encourage mention handling without forcing replies and add regression coverage for no_reply mention flows.
This commit is contained in:
parent
cf93e054c3
commit
34330d9b7a
|
|
@ -8,6 +8,7 @@ defaults:
|
|||
issueAssistant:
|
||||
enabled: true
|
||||
maxConcurrentIssues: 3
|
||||
maxResponsesAfterHuman: 1
|
||||
mentionTriggers: ["@codex", "@helper"]
|
||||
responders:
|
||||
- id: codex
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ defaults:
|
|||
issueAssistant:
|
||||
enabled: true
|
||||
maxConcurrentIssues: 3
|
||||
maxResponsesAfterHuman: 1
|
||||
mentionTriggers: ["@codex", "@helper"]
|
||||
commentPrefixTemplate: |
|
||||
> [!NOTE]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ defaults:
|
|||
issueAssistant:
|
||||
enabled: true
|
||||
maxConcurrentIssues: 3
|
||||
maxResponsesAfterHuman: 1
|
||||
mentionTriggers: ["@codex", "@helper"]
|
||||
responders:
|
||||
- id: codex
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ const String defaultIssueAssistantPrompt = '''
|
|||
You are a repository issue thread assistant.
|
||||
|
||||
Decide whether to reply. Rules:
|
||||
- If the latest trigger explicitly mentions the assistant, always return "reply".
|
||||
- Treat an explicit assistant mention as a strong signal to consider replying, not a requirement.
|
||||
- If the discussion already has a good enough answer, return "no_reply".
|
||||
- Be conservative about replying to bot or automation comments, or comments that only repeat mention triggers without a clear user need.
|
||||
- Supported modes are "planning" and "bug_verification".
|
||||
- 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.
|
||||
|
|
@ -48,6 +49,9 @@ Repository metadata:
|
|||
- issue_url: {{issue_url}}
|
||||
- mention_triggers: {{mention_triggers}}
|
||||
- trigger: {{trigger}}
|
||||
- latest_author_is_bot: {{latest_author_is_bot}}
|
||||
- responses_after_human: {{responses_after_human}}
|
||||
- max_responses_after_human: {{max_responses_after_human}}
|
||||
- capabilities: {{capabilities}}
|
||||
|
||||
Issue body:
|
||||
|
|
@ -143,6 +147,7 @@ class AppConfig {
|
|||
pollInterval: '30s',
|
||||
),
|
||||
maxConcurrentIssues: 3,
|
||||
maxResponsesAfterHuman: 1,
|
||||
mentionTriggers: const ['@codex', '@helper'],
|
||||
),
|
||||
),
|
||||
|
|
@ -346,6 +351,7 @@ IssueAssistantConfigDocument? _resolveProjectIssueAssistantDocument({
|
|||
enabled: issueAssistant?.enabled,
|
||||
eventSource: trackerEventSource,
|
||||
maxConcurrentIssues: issueAssistant?.maxConcurrentIssues,
|
||||
maxResponsesAfterHuman: issueAssistant?.maxResponsesAfterHuman,
|
||||
mentionTriggers: issueAssistant?.mentionTriggers,
|
||||
prompt: issueAssistant?.prompt,
|
||||
responders: issueAssistant?.responders,
|
||||
|
|
@ -389,6 +395,7 @@ class IssueAssistantConfig {
|
|||
required this.enabled,
|
||||
required this.eventSource,
|
||||
required this.maxConcurrentIssues,
|
||||
required this.maxResponsesAfterHuman,
|
||||
required this.mentionTriggers,
|
||||
required this.prompt,
|
||||
required this.responders,
|
||||
|
|
@ -402,6 +409,7 @@ class IssueAssistantConfig {
|
|||
final bool enabled;
|
||||
final IssueEventSourceConfig eventSource;
|
||||
final int maxConcurrentIssues;
|
||||
final int maxResponsesAfterHuman;
|
||||
final List<String> mentionTriggers;
|
||||
final String prompt;
|
||||
final List<CliResponderConfig> responders;
|
||||
|
|
@ -431,6 +439,7 @@ class IssueAssistantConfig {
|
|||
enabled: enabled,
|
||||
eventSource: eventSource ?? this.eventSource,
|
||||
maxConcurrentIssues: maxConcurrentIssues,
|
||||
maxResponsesAfterHuman: maxResponsesAfterHuman,
|
||||
mentionTriggers: mentionTriggers,
|
||||
prompt: prompt,
|
||||
responders: responders,
|
||||
|
|
@ -594,6 +603,11 @@ class _IssueAssistantConfigResolver {
|
|||
fallback: base?.maxConcurrentIssues ?? 3,
|
||||
fieldName: 'maxConcurrentIssues',
|
||||
),
|
||||
maxResponsesAfterHuman: _resolveNonNegativeInt(
|
||||
value: overlay?.maxResponsesAfterHuman,
|
||||
fallback: base?.maxResponsesAfterHuman ?? 1,
|
||||
fieldName: 'maxResponsesAfterHuman',
|
||||
),
|
||||
mentionTriggers:
|
||||
overlay?.mentionTriggers ?? base?.mentionTriggers ?? const <String>[],
|
||||
prompt: overlay?.prompt ?? base?.prompt ?? defaultIssueAssistantPrompt,
|
||||
|
|
@ -769,6 +783,20 @@ int _resolvePositiveInt({
|
|||
return resolved;
|
||||
}
|
||||
|
||||
int _resolveNonNegativeInt({
|
||||
required int? value,
|
||||
required int fallback,
|
||||
required String fieldName,
|
||||
}) {
|
||||
final resolved = value ?? fallback;
|
||||
if (resolved < 0) {
|
||||
throw FormatException(
|
||||
'$fieldName must be an integer greater than or equal to 0.',
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
String _expandHome(String path) {
|
||||
if (!path.startsWith('~/')) {
|
||||
return p.normalize(p.absolute(path));
|
||||
|
|
@ -1168,6 +1196,7 @@ ${_YamlEmitter().write({'defaults': defaultsSection})}
|
|||
|
||||
# capabilities: ["planning", "bug_verification"]
|
||||
# commentTag: code-work-spawner
|
||||
# maxResponsesAfterHuman: 1
|
||||
# commentPrefixTemplate: |
|
||||
${_commentBlock(defaultCommentPrefixTemplate, indent: ' # ')}
|
||||
# commentFooterTemplate: |
|
||||
|
|
@ -1196,6 +1225,7 @@ ${_YamlEmitter().write({'projects': projectsSection})}
|
|||
|
||||
# issueAssistant:
|
||||
# enabled: ${assistant?.enabled ?? true}
|
||||
# maxResponsesAfterHuman: ${assistant?.maxResponsesAfterHuman ?? 1}
|
||||
# mentionTriggers: ["@helper"]
|
||||
# sessionPrefix: sample
|
||||
'''
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ class IssueAssistantConfigDocument {
|
|||
this.enabled,
|
||||
this.eventSource,
|
||||
this.maxConcurrentIssues,
|
||||
this.maxResponsesAfterHuman,
|
||||
this.mentionTriggers,
|
||||
this.prompt,
|
||||
this.responders,
|
||||
|
|
@ -142,6 +143,7 @@ class IssueAssistantConfigDocument {
|
|||
final bool? enabled;
|
||||
final IssueAssistantEventSourceDocument? eventSource;
|
||||
final int? maxConcurrentIssues;
|
||||
final int? maxResponsesAfterHuman;
|
||||
final List<String>? mentionTriggers;
|
||||
final String? prompt;
|
||||
final List<CliResponderConfigDocument>? responders;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ class CliResponderRunner {
|
|||
required ProjectConfig project,
|
||||
required IssueThread thread,
|
||||
required String prompt,
|
||||
required bool forceReply,
|
||||
required String workspacePath,
|
||||
}) async {
|
||||
final context = <String, String>{
|
||||
|
|
@ -23,7 +22,6 @@ class CliResponderRunner {
|
|||
'workspace_path': workspacePath,
|
||||
'issue_number': thread.issue.number.toString(),
|
||||
'thread_json': encodePrettyJson(thread.toJson()),
|
||||
'force_reply': forceReply.toString(),
|
||||
};
|
||||
|
||||
final stdinPayload = renderTemplate(responder.stdinTemplate, context);
|
||||
|
|
@ -38,7 +36,6 @@ class CliResponderRunner {
|
|||
'CWS_PROJECT_PATH': project.path,
|
||||
'CWS_WORKSPACE_PATH': workspacePath,
|
||||
'CWS_ISSUE_NUMBER': thread.issue.number.toString(),
|
||||
'CWS_FORCE_REPLY': forceReply.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -190,23 +187,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -200,9 +200,11 @@ class IssueAssistantApp {
|
|||
}
|
||||
|
||||
final trigger = _determineTrigger(project, thread);
|
||||
final skipReason = _skipEvaluationReason(project, thread);
|
||||
_log(
|
||||
'evaluate issue=${project.key}#${issue.number} '
|
||||
'comments=${thread.comments.length} trigger=$trigger',
|
||||
'comments=${thread.comments.length} trigger=$trigger'
|
||||
'${skipReason == null ? "" : " skip_reason=$skipReason"}',
|
||||
);
|
||||
final baseNotificationContext = NotificationContext(
|
||||
projectKey: project.key,
|
||||
|
|
@ -238,7 +240,21 @@ class IssueAssistantApp {
|
|||
);
|
||||
|
||||
await database.updateJobStatus(jobId, JobStatus.running);
|
||||
final forceReply = trigger == 'mention';
|
||||
if (skipReason != null) {
|
||||
_log('issue=${project.key}#${issue.number} skipped $skipReason');
|
||||
await database.upsertIssueThread(
|
||||
projectKey: project.key,
|
||||
issueNumber: issue.number,
|
||||
fingerprint: fingerprint,
|
||||
updatedAt: issue.updatedAt,
|
||||
lastEvaluatedFingerprint: fingerprint,
|
||||
lastDecision: record?.lastDecision,
|
||||
lastCommentId: record?.lastCommentId,
|
||||
lastCommentUrl: record?.lastCommentUrl,
|
||||
);
|
||||
await database.updateJobStatus(jobId, JobStatus.skipped);
|
||||
return;
|
||||
}
|
||||
final prompt = _buildPrompt(
|
||||
project: project,
|
||||
thread: thread,
|
||||
|
|
@ -289,7 +305,6 @@ class IssueAssistantApp {
|
|||
project: project,
|
||||
thread: thread,
|
||||
prompt: prompt,
|
||||
forceReply: forceReply,
|
||||
workspacePath: workspacePath,
|
||||
);
|
||||
await database.addExecutionRun(
|
||||
|
|
@ -313,15 +328,7 @@ class IssueAssistantApp {
|
|||
'title="${thread.issue.title}", '
|
||||
'decision=${result.decision}, mode=${result.mode}',
|
||||
);
|
||||
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}',
|
||||
);
|
||||
}
|
||||
selectedResult = result;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
|
@ -508,6 +515,16 @@ class IssueAssistantApp {
|
|||
).convert(thread.toJson()),
|
||||
'mention_triggers': project.issueAssistant.mentionTriggers.join(', '),
|
||||
'trigger': trigger,
|
||||
'max_responses_after_human': project.issueAssistant.maxResponsesAfterHuman
|
||||
.toString(),
|
||||
'responses_after_human': _countResponsesSinceLatestHuman(
|
||||
project,
|
||||
thread,
|
||||
).toString(),
|
||||
'latest_author_is_bot': _isLatestContributionFromBot(
|
||||
project,
|
||||
thread,
|
||||
).toString(),
|
||||
'capabilities': project.issueAssistant.capabilities
|
||||
.map((capability) => capability.name)
|
||||
.join(', '),
|
||||
|
|
@ -523,18 +540,56 @@ class IssueAssistantApp {
|
|||
final mention = project.issueAssistant.mentionTriggers.any(
|
||||
(trigger) => latestTextLower.contains(trigger.toLowerCase()),
|
||||
);
|
||||
if (mention && !_looksLikeBot(project, latestAuthor)) {
|
||||
if (mention && !_looksLikeBot(project, latestAuthor, latestText)) {
|
||||
return 'mention';
|
||||
}
|
||||
return 'update';
|
||||
}
|
||||
|
||||
bool _looksLikeBot(ProjectConfig project, String login) {
|
||||
String? _skipEvaluationReason(ProjectConfig project, IssueThread thread) {
|
||||
if (_isLatestContributionFromBot(project, thread)) {
|
||||
return 'latest_comment_from_bot';
|
||||
}
|
||||
if (_countResponsesSinceLatestHuman(project, thread) >=
|
||||
project.issueAssistant.maxResponsesAfterHuman) {
|
||||
return 'max_responses_after_human_reached';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isLatestContributionFromBot(ProjectConfig project, IssueThread thread) {
|
||||
final latestText = thread.latestComment?.body ?? thread.issue.body;
|
||||
final latestAuthor =
|
||||
thread.latestComment?.userLogin ?? thread.issue.userLogin;
|
||||
return _looksLikeBot(project, latestAuthor, latestText);
|
||||
}
|
||||
|
||||
int _countResponsesSinceLatestHuman(
|
||||
ProjectConfig project,
|
||||
IssueThread thread,
|
||||
) {
|
||||
var count = 0;
|
||||
for (final comment in thread.comments.reversed) {
|
||||
if (_looksLikeBot(project, comment.userLogin, comment.body)) {
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
bool _looksLikeBot(ProjectConfig project, String login, String body) {
|
||||
final lower = login.toLowerCase();
|
||||
if (lower.endsWith('[bot]')) {
|
||||
return true;
|
||||
}
|
||||
return lower.contains(project.issueAssistant.commentTag.toLowerCase());
|
||||
if (lower.contains(project.issueAssistant.commentTag.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
final tagPrefix =
|
||||
'<!-- ${project.issueAssistant.commentTag.toLowerCase()}:';
|
||||
return body.toLowerCase().contains(tagPrefix);
|
||||
}
|
||||
|
||||
bool _looksLikeBugVerification(IssueThread thread) {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ defaults:
|
|||
issueAssistant:
|
||||
enabled: true
|
||||
pollInterval: 5m
|
||||
maxResponsesAfterHuman: 2
|
||||
mentionTriggers: ["@helper"]
|
||||
prompt: "default {{repo}}"
|
||||
commentPrefixTemplate: "> default prefix {{gh_login}}"
|
||||
|
|
@ -93,6 +94,10 @@ projects:
|
|||
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
||||
'@helper',
|
||||
]);
|
||||
expect(
|
||||
config.projects['sample']!.issueAssistant.maxResponsesAfterHuman,
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
config.projects['sample']!.issueAssistant.responders.single.id,
|
||||
'fallback',
|
||||
|
|
@ -1201,6 +1206,7 @@ projects:
|
|||
'@codex',
|
||||
'@helper',
|
||||
]);
|
||||
expect(config.defaults.maxResponsesAfterHuman, 1);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
|
|
@ -1269,6 +1275,36 @@ projects:
|
|||
config.projects['sample']!.issueAssistant.eventSource,
|
||||
isA<PollingIssueEventSourceConfig>(),
|
||||
);
|
||||
expect(config.defaults.maxResponsesAfterHuman, 1);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Parse repository example configs
|
||||
/// Given the checked-in example config files
|
||||
/// When loading each example through the normal config parser
|
||||
/// Then each example parses successfully
|
||||
/// And the mention loop guard default is available in the loaded config
|
||||
/// ```
|
||||
test('Parse repository example configs', () async {
|
||||
// Given the checked-in example config files.
|
||||
final repoRoot = Directory.current.path;
|
||||
final examplePaths = [
|
||||
p.join(repoRoot, 'examples', 'simple-github.yaml'),
|
||||
p.join(repoRoot, 'examples', 'simple-gitlab.yaml'),
|
||||
p.join(repoRoot, 'examples', 'simple-gitea.yaml'),
|
||||
];
|
||||
|
||||
// When loading each example through the normal config parser.
|
||||
final configs = await Future.wait(examplePaths.map(AppConfig.load));
|
||||
|
||||
// Then each example parses successfully.
|
||||
expect(configs, hasLength(3));
|
||||
|
||||
// And the mention loop guard default is available in the loaded config.
|
||||
for (final config in configs) {
|
||||
expect(config.projects, isNotEmpty);
|
||||
expect(config.defaults.maxResponsesAfterHuman, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ void main() {
|
|||
result.stdout,
|
||||
contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'),
|
||||
);
|
||||
expect(result.stdout, contains('# maxResponsesAfterHuman: 1'));
|
||||
expect(result.stdout, contains('# - type: desktop'));
|
||||
expect(result.stdout, contains('issueTracker:'));
|
||||
expect(result.stdout, contains('provider: github'));
|
||||
|
|
@ -69,6 +70,7 @@ void main() {
|
|||
await configFile.writeAsString(result.stdout);
|
||||
final config = await AppConfig.load(configFile.path);
|
||||
expect(config.projects['sample'], isNotNull);
|
||||
expect(config.defaults.maxResponsesAfterHuman, 1);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
|
|
@ -110,10 +112,12 @@ void main() {
|
|||
expect(contents, contains('defaults:'));
|
||||
expect(contents, contains('issueTracker:'));
|
||||
expect(contents, contains('provider: github'));
|
||||
expect(contents, contains('# maxResponsesAfterHuman: 1'));
|
||||
expect(contents, contains('scm:'));
|
||||
expect(contents, contains('projects:'));
|
||||
final config = await AppConfig.load(outputFile.path);
|
||||
expect(config.projects['sample'], isNotNull);
|
||||
expect(config.defaults.maxResponsesAfterHuman, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,6 +222,223 @@ projects:
|
|||
},
|
||||
);
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Respect no_reply for an explicit assistant mention
|
||||
/// Given a temporary project checkout that can be used as the local repo path
|
||||
/// And GitHub issue data containing a comment with an explicit assistant mention
|
||||
/// And a responder that explicitly returns no_reply
|
||||
/// And an orchestrator-style config pointing to the fake repo and responder
|
||||
/// When the app processes one polling cycle
|
||||
/// Then it records the evaluation result as no_reply
|
||||
/// And it does not post a GitHub comment for that mention
|
||||
/// ```
|
||||
test('Respect no_reply for an explicit assistant mention', () async {
|
||||
// Given a temporary project checkout that can be used as the local repo path.
|
||||
final sandbox = await Directory.systemTemp.createTemp(
|
||||
'cws-mentioned-no-reply-',
|
||||
);
|
||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||
await initGitRepo(repoDir);
|
||||
|
||||
// And GitHub issue data containing a comment with 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': 43,
|
||||
'title': 'Need mention handling',
|
||||
'body': 'Please avoid circular replies.',
|
||||
'state': 'open',
|
||||
'html_url': 'https://example.test/issues/43',
|
||||
'updated_at': '2026-04-04T13:30:00Z',
|
||||
'user': {'login': 'reporter'},
|
||||
'labels': const [],
|
||||
},
|
||||
];
|
||||
final commentsData = [
|
||||
{
|
||||
'id': 430,
|
||||
'body': '@helper no need to answer if the thread is already covered',
|
||||
'html_url': 'https://example.test/issues/43#issuecomment-430',
|
||||
'created_at': '2026-04-04T13:30:00Z',
|
||||
'updated_at': '2026-04-04T13:30:00Z',
|
||||
'user': {'login': 'reporter'},
|
||||
},
|
||||
];
|
||||
await writeFakeGhScript(
|
||||
ghScript: ghScript,
|
||||
issueListResponse: issueData,
|
||||
commentResponses: {43: commentsData},
|
||||
ghLog: ghLog,
|
||||
);
|
||||
|
||||
// And a responder that explicitly returns no_reply.
|
||||
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||
await writeResponderScript(responderScript, {
|
||||
'decision': 'no_reply',
|
||||
'mode': 'planning',
|
||||
'summary': 'mention acknowledged without posting',
|
||||
});
|
||||
|
||||
// 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();
|
||||
final thread = await app.database.findIssueThread('sample', 43);
|
||||
await app.close();
|
||||
|
||||
// Then it records the evaluation result as no_reply.
|
||||
expect(thread, isNotNull);
|
||||
expect(thread!.lastDecision, 'no_reply');
|
||||
|
||||
// And it does not post a GitHub comment for that mention.
|
||||
final logLines = await ghLog.readAsLines();
|
||||
expect(
|
||||
logLines
|
||||
.where(
|
||||
(line) => line.contains('repos/owner/sample/issues/43/comments'),
|
||||
)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
expect(thread.lastCommentId, isNull);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Skip bot-authored mention loops after a human reply budget is exhausted
|
||||
/// Given a temporary project checkout that can be used as the local repo path
|
||||
/// And GitHub issue data containing a human mention followed by an assistant-tagged mention
|
||||
/// And a responder that would reply if invoked
|
||||
/// And an orchestrator-style config limiting replies after a human comment to one
|
||||
/// When the app processes one polling cycle
|
||||
/// Then it does not post a GitHub comment for the bot-authored latest update
|
||||
/// ```
|
||||
test(
|
||||
'Skip bot-authored mention loops after a human reply budget is exhausted',
|
||||
() async {
|
||||
// Given a temporary project checkout that can be used as the local repo path.
|
||||
final sandbox = await Directory.systemTemp.createTemp(
|
||||
'cws-bot-loop-guard-',
|
||||
);
|
||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||
await initGitRepo(repoDir);
|
||||
|
||||
// And GitHub issue data containing a human mention followed by an assistant-tagged mention.
|
||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||
final issueData = [
|
||||
{
|
||||
'number': 44,
|
||||
'title': 'Avoid circular replies',
|
||||
'body': 'Need to stop assistant loops.',
|
||||
'state': 'open',
|
||||
'html_url': 'https://example.test/issues/44',
|
||||
'updated_at': '2026-04-04T14:00:00Z',
|
||||
'user': {'login': 'reporter'},
|
||||
'labels': const [],
|
||||
},
|
||||
];
|
||||
final commentsData = [
|
||||
{
|
||||
'id': 440,
|
||||
'body': '@helper please review this',
|
||||
'html_url': 'https://example.test/issues/44#issuecomment-440',
|
||||
'created_at': '2026-04-04T14:00:00Z',
|
||||
'updated_at': '2026-04-04T14:00:00Z',
|
||||
'user': {'login': 'reporter'},
|
||||
},
|
||||
{
|
||||
'id': 441,
|
||||
'body':
|
||||
'@helper follow-up from automation\n<!-- code-work-spawner:previous -->',
|
||||
'html_url': 'https://example.test/issues/44#issuecomment-441',
|
||||
'created_at': '2026-04-04T14:05:00Z',
|
||||
'updated_at': '2026-04-04T14:05:00Z',
|
||||
'user': {'login': 'maintainer'},
|
||||
},
|
||||
];
|
||||
await writeFakeGhScript(
|
||||
ghScript: ghScript,
|
||||
issueListResponse: issueData,
|
||||
commentResponses: {44: commentsData},
|
||||
ghLog: ghLog,
|
||||
);
|
||||
|
||||
// And a responder that would reply if invoked.
|
||||
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||
await writeResponderScript(responderScript, {
|
||||
'decision': 'reply',
|
||||
'mode': 'planning',
|
||||
'markdown': 'This should never be posted.',
|
||||
'summary': 'unexpected responder execution',
|
||||
});
|
||||
|
||||
// And an orchestrator-style config limiting replies after a human comment to one.
|
||||
final configFile = File(
|
||||
p.join(sandbox.path, 'agent-orchestrator.yaml'),
|
||||
);
|
||||
await configFile.writeAsString('''
|
||||
defaults:
|
||||
issueAssistant:
|
||||
enabled: true
|
||||
pollInterval: 5m
|
||||
maxResponsesAfterHuman: 1
|
||||
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();
|
||||
final thread = await app.database.findIssueThread('sample', 44);
|
||||
await app.close();
|
||||
|
||||
// Then it does not post a GitHub comment for the bot-authored latest update.
|
||||
final logLines = await ghLog.readAsLines();
|
||||
expect(
|
||||
logLines
|
||||
.where(
|
||||
(line) =>
|
||||
line.contains('repos/owner/sample/issues/44/comments'),
|
||||
)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
expect(thread, isNotNull);
|
||||
expect(thread!.lastCommentId, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Log raw responder output for debugging
|
||||
/// Given a temporary project checkout that can be used as the local repo path
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ ProjectConfig _project({
|
|||
reconcileInterval: const Duration(seconds: 30),
|
||||
),
|
||||
maxConcurrentIssues: 3,
|
||||
maxResponsesAfterHuman: 1,
|
||||
mentionTriggers: const ['@helper'],
|
||||
prompt: defaultIssueAssistantPrompt,
|
||||
responders: const <CliResponderConfig>[],
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ void main() {
|
|||
pollInterval: Duration(seconds: 30),
|
||||
),
|
||||
maxConcurrentIssues: 3,
|
||||
maxResponsesAfterHuman: 1,
|
||||
mentionTriggers: const ['@helper'],
|
||||
prompt: defaultIssueAssistantPrompt,
|
||||
responders: const <CliResponderConfig>[],
|
||||
|
|
@ -135,6 +136,7 @@ void main() {
|
|||
pollInterval: Duration(seconds: 30),
|
||||
),
|
||||
maxConcurrentIssues: 3,
|
||||
maxResponsesAfterHuman: 1,
|
||||
mentionTriggers: const ['@helper'],
|
||||
prompt: defaultIssueAssistantPrompt,
|
||||
responders: const <CliResponderConfig>[],
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ void registerIssueAssistantAppRunOnceWorktreeTests() {
|
|||
/// ```
|
||||
group('IssueAssistantApp.runOnce', () {
|
||||
/// ```gherkin
|
||||
/// Scenario: Run bug verification mention replies from an ephemeral worktree with issue context
|
||||
/// Scenario: Run bug verification mention evaluation 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 a bug verification mention
|
||||
/// And a responder that records its working directory and stdin before returning no_reply
|
||||
|
|
@ -25,10 +25,10 @@ void registerIssueAssistantAppRunOnceWorktreeTests() {
|
|||
/// 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
|
||||
/// And the app does not post a GitHub comment when the responder returns no_reply
|
||||
/// ```
|
||||
test(
|
||||
'Run bug verification mention replies from an ephemeral worktree with issue context',
|
||||
'Run bug verification mention evaluation 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(
|
||||
|
|
@ -128,7 +128,7 @@ projects:
|
|||
contains('@helper please verify and reproduce this bug'),
|
||||
);
|
||||
|
||||
// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant.
|
||||
// And the app does not post a GitHub comment when the responder returns no_reply.
|
||||
final logLines = await ghLog.readAsLines();
|
||||
expect(
|
||||
logLines
|
||||
|
|
@ -137,7 +137,7 @@ projects:
|
|||
line.contains('repos/owner/sample/issues/24/comments'),
|
||||
)
|
||||
.length,
|
||||
2,
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue