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:
insleker 2026-04-10 14:05:36 +08:00 committed by existedinnettw
parent cf93e054c3
commit 34330d9b7a
13 changed files with 371 additions and 41 deletions

View File

@ -8,6 +8,7 @@ defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
maxConcurrentIssues: 3 maxConcurrentIssues: 3
maxResponsesAfterHuman: 1
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]
responders: responders:
- id: codex - id: codex

View File

@ -8,6 +8,7 @@ defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
maxConcurrentIssues: 3 maxConcurrentIssues: 3
maxResponsesAfterHuman: 1
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]
commentPrefixTemplate: | commentPrefixTemplate: |
> [!NOTE] > [!NOTE]

View File

@ -8,6 +8,7 @@ defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
maxConcurrentIssues: 3 maxConcurrentIssues: 3
maxResponsesAfterHuman: 1
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]
responders: responders:
- id: codex - id: codex

View File

@ -23,8 +23,9 @@ const String defaultIssueAssistantPrompt = '''
You are a repository issue thread assistant. You are a repository issue thread assistant.
Decide whether to reply. Rules: 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". - 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". - Supported modes are "planning" and "bug_verification".
- 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.
@ -48,6 +49,9 @@ Repository metadata:
- issue_url: {{issue_url}} - issue_url: {{issue_url}}
- mention_triggers: {{mention_triggers}} - mention_triggers: {{mention_triggers}}
- trigger: {{trigger}} - 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}} - capabilities: {{capabilities}}
Issue body: Issue body:
@ -143,6 +147,7 @@ class AppConfig {
pollInterval: '30s', pollInterval: '30s',
), ),
maxConcurrentIssues: 3, maxConcurrentIssues: 3,
maxResponsesAfterHuman: 1,
mentionTriggers: const ['@codex', '@helper'], mentionTriggers: const ['@codex', '@helper'],
), ),
), ),
@ -346,6 +351,7 @@ IssueAssistantConfigDocument? _resolveProjectIssueAssistantDocument({
enabled: issueAssistant?.enabled, enabled: issueAssistant?.enabled,
eventSource: trackerEventSource, eventSource: trackerEventSource,
maxConcurrentIssues: issueAssistant?.maxConcurrentIssues, maxConcurrentIssues: issueAssistant?.maxConcurrentIssues,
maxResponsesAfterHuman: issueAssistant?.maxResponsesAfterHuman,
mentionTriggers: issueAssistant?.mentionTriggers, mentionTriggers: issueAssistant?.mentionTriggers,
prompt: issueAssistant?.prompt, prompt: issueAssistant?.prompt,
responders: issueAssistant?.responders, responders: issueAssistant?.responders,
@ -389,6 +395,7 @@ class IssueAssistantConfig {
required this.enabled, required this.enabled,
required this.eventSource, required this.eventSource,
required this.maxConcurrentIssues, required this.maxConcurrentIssues,
required this.maxResponsesAfterHuman,
required this.mentionTriggers, required this.mentionTriggers,
required this.prompt, required this.prompt,
required this.responders, required this.responders,
@ -402,6 +409,7 @@ class IssueAssistantConfig {
final bool enabled; final bool enabled;
final IssueEventSourceConfig eventSource; final IssueEventSourceConfig eventSource;
final int maxConcurrentIssues; final int maxConcurrentIssues;
final int maxResponsesAfterHuman;
final List<String> mentionTriggers; final List<String> mentionTriggers;
final String prompt; final String prompt;
final List<CliResponderConfig> responders; final List<CliResponderConfig> responders;
@ -431,6 +439,7 @@ class IssueAssistantConfig {
enabled: enabled, enabled: enabled,
eventSource: eventSource ?? this.eventSource, eventSource: eventSource ?? this.eventSource,
maxConcurrentIssues: maxConcurrentIssues, maxConcurrentIssues: maxConcurrentIssues,
maxResponsesAfterHuman: maxResponsesAfterHuman,
mentionTriggers: mentionTriggers, mentionTriggers: mentionTriggers,
prompt: prompt, prompt: prompt,
responders: responders, responders: responders,
@ -594,6 +603,11 @@ class _IssueAssistantConfigResolver {
fallback: base?.maxConcurrentIssues ?? 3, fallback: base?.maxConcurrentIssues ?? 3,
fieldName: 'maxConcurrentIssues', fieldName: 'maxConcurrentIssues',
), ),
maxResponsesAfterHuman: _resolveNonNegativeInt(
value: overlay?.maxResponsesAfterHuman,
fallback: base?.maxResponsesAfterHuman ?? 1,
fieldName: 'maxResponsesAfterHuman',
),
mentionTriggers: mentionTriggers:
overlay?.mentionTriggers ?? base?.mentionTriggers ?? const <String>[], overlay?.mentionTriggers ?? base?.mentionTriggers ?? const <String>[],
prompt: overlay?.prompt ?? base?.prompt ?? defaultIssueAssistantPrompt, prompt: overlay?.prompt ?? base?.prompt ?? defaultIssueAssistantPrompt,
@ -769,6 +783,20 @@ int _resolvePositiveInt({
return resolved; 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) { String _expandHome(String path) {
if (!path.startsWith('~/')) { if (!path.startsWith('~/')) {
return p.normalize(p.absolute(path)); return p.normalize(p.absolute(path));
@ -1168,6 +1196,7 @@ ${_YamlEmitter().write({'defaults': defaultsSection})}
# capabilities: ["planning", "bug_verification"] # capabilities: ["planning", "bug_verification"]
# commentTag: code-work-spawner # commentTag: code-work-spawner
# maxResponsesAfterHuman: 1
# commentPrefixTemplate: | # commentPrefixTemplate: |
${_commentBlock(defaultCommentPrefixTemplate, indent: ' # ')} ${_commentBlock(defaultCommentPrefixTemplate, indent: ' # ')}
# commentFooterTemplate: | # commentFooterTemplate: |
@ -1196,6 +1225,7 @@ ${_YamlEmitter().write({'projects': projectsSection})}
# issueAssistant: # issueAssistant:
# enabled: ${assistant?.enabled ?? true} # enabled: ${assistant?.enabled ?? true}
# maxResponsesAfterHuman: ${assistant?.maxResponsesAfterHuman ?? 1}
# mentionTriggers: ["@helper"] # mentionTriggers: ["@helper"]
# sessionPrefix: sample # sessionPrefix: sample
''' '''

View File

@ -129,6 +129,7 @@ class IssueAssistantConfigDocument {
this.enabled, this.enabled,
this.eventSource, this.eventSource,
this.maxConcurrentIssues, this.maxConcurrentIssues,
this.maxResponsesAfterHuman,
this.mentionTriggers, this.mentionTriggers,
this.prompt, this.prompt,
this.responders, this.responders,
@ -142,6 +143,7 @@ class IssueAssistantConfigDocument {
final bool? enabled; final bool? enabled;
final IssueAssistantEventSourceDocument? eventSource; final IssueAssistantEventSourceDocument? eventSource;
final int? maxConcurrentIssues; final int? maxConcurrentIssues;
final int? maxResponsesAfterHuman;
final List<String>? mentionTriggers; final List<String>? mentionTriggers;
final String? prompt; final String? prompt;
final List<CliResponderConfigDocument>? responders; final List<CliResponderConfigDocument>? responders;

View File

@ -12,7 +12,6 @@ class CliResponderRunner {
required ProjectConfig project, required ProjectConfig project,
required IssueThread thread, required IssueThread thread,
required String prompt, required String prompt,
required bool forceReply,
required String workspacePath, required String workspacePath,
}) async { }) async {
final context = <String, String>{ final context = <String, String>{
@ -23,7 +22,6 @@ class CliResponderRunner {
'workspace_path': workspacePath, 'workspace_path': workspacePath,
'issue_number': thread.issue.number.toString(), 'issue_number': thread.issue.number.toString(),
'thread_json': encodePrettyJson(thread.toJson()), 'thread_json': encodePrettyJson(thread.toJson()),
'force_reply': forceReply.toString(),
}; };
final stdinPayload = renderTemplate(responder.stdinTemplate, context); final stdinPayload = renderTemplate(responder.stdinTemplate, context);
@ -38,7 +36,6 @@ class CliResponderRunner {
'CWS_PROJECT_PATH': project.path, 'CWS_PROJECT_PATH': project.path,
'CWS_WORKSPACE_PATH': workspacePath, 'CWS_WORKSPACE_PATH': workspacePath,
'CWS_ISSUE_NUMBER': thread.issue.number.toString(), 'CWS_ISSUE_NUMBER': thread.issue.number.toString(),
'CWS_FORCE_REPLY': forceReply.toString(),
}, },
); );
@ -190,23 +187,6 @@ class ResponderResult {
bool get shouldReply => decision == 'reply'; 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( factory ResponderResult.fromJson(
Map<String, dynamic> json, { Map<String, dynamic> json, {
required String responderId, required String responderId,

View File

@ -200,9 +200,11 @@ class IssueAssistantApp {
} }
final trigger = _determineTrigger(project, thread); final trigger = _determineTrigger(project, thread);
final skipReason = _skipEvaluationReason(project, thread);
_log( _log(
'evaluate issue=${project.key}#${issue.number} ' '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( final baseNotificationContext = NotificationContext(
projectKey: project.key, projectKey: project.key,
@ -238,7 +240,21 @@ class IssueAssistantApp {
); );
await database.updateJobStatus(jobId, JobStatus.running); 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( final prompt = _buildPrompt(
project: project, project: project,
thread: thread, thread: thread,
@ -289,7 +305,6 @@ class IssueAssistantApp {
project: project, project: project,
thread: thread, thread: thread,
prompt: prompt, prompt: prompt,
forceReply: forceReply,
workspacePath: workspacePath, workspacePath: workspacePath,
); );
await database.addExecutionRun( await database.addExecutionRun(
@ -313,15 +328,7 @@ class IssueAssistantApp {
'title="${thread.issue.title}", ' 'title="${thread.issue.title}", '
'decision=${result.decision}, mode=${result.mode}', 'decision=${result.decision}, mode=${result.mode}',
); );
selectedResult = forceReply && !result.shouldReply selectedResult = result;
? result.forceReply()
: result;
if (forceReply && !result.shouldReply) {
_log(
'responder=${responder.id} issue=${project.key}#${issue.number} '
'override=force_reply original_decision=${result.decision}',
);
}
break; break;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
@ -508,6 +515,16 @@ class IssueAssistantApp {
).convert(thread.toJson()), ).convert(thread.toJson()),
'mention_triggers': project.issueAssistant.mentionTriggers.join(', '), 'mention_triggers': project.issueAssistant.mentionTriggers.join(', '),
'trigger': trigger, '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 'capabilities': project.issueAssistant.capabilities
.map((capability) => capability.name) .map((capability) => capability.name)
.join(', '), .join(', '),
@ -523,18 +540,56 @@ class IssueAssistantApp {
final mention = project.issueAssistant.mentionTriggers.any( final mention = project.issueAssistant.mentionTriggers.any(
(trigger) => latestTextLower.contains(trigger.toLowerCase()), (trigger) => latestTextLower.contains(trigger.toLowerCase()),
); );
if (mention && !_looksLikeBot(project, latestAuthor)) { if (mention && !_looksLikeBot(project, latestAuthor, latestText)) {
return 'mention'; return 'mention';
} }
return 'update'; 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(); final lower = login.toLowerCase();
if (lower.endsWith('[bot]')) { if (lower.endsWith('[bot]')) {
return true; 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) { bool _looksLikeBugVerification(IssueThread thread) {

View File

@ -66,6 +66,7 @@ defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
pollInterval: 5m pollInterval: 5m
maxResponsesAfterHuman: 2
mentionTriggers: ["@helper"] mentionTriggers: ["@helper"]
prompt: "default {{repo}}" prompt: "default {{repo}}"
commentPrefixTemplate: "> default prefix {{gh_login}}" commentPrefixTemplate: "> default prefix {{gh_login}}"
@ -93,6 +94,10 @@ projects:
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [ expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
'@helper', '@helper',
]); ]);
expect(
config.projects['sample']!.issueAssistant.maxResponsesAfterHuman,
2,
);
expect( expect(
config.projects['sample']!.issueAssistant.responders.single.id, config.projects['sample']!.issueAssistant.responders.single.id,
'fallback', 'fallback',
@ -1201,6 +1206,7 @@ projects:
'@codex', '@codex',
'@helper', '@helper',
]); ]);
expect(config.defaults.maxResponsesAfterHuman, 1);
}); });
/// ```gherkin /// ```gherkin
@ -1269,6 +1275,36 @@ projects:
config.projects['sample']!.issueAssistant.eventSource, config.projects['sample']!.issueAssistant.eventSource,
isA<PollingIssueEventSourceConfig>(), 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);
}
}); });
}); });
} }

View File

@ -59,6 +59,7 @@ void main() {
result.stdout, result.stdout,
contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'), contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'),
); );
expect(result.stdout, contains('# maxResponsesAfterHuman: 1'));
expect(result.stdout, contains('# - type: desktop')); expect(result.stdout, contains('# - type: desktop'));
expect(result.stdout, contains('issueTracker:')); expect(result.stdout, contains('issueTracker:'));
expect(result.stdout, contains('provider: github')); expect(result.stdout, contains('provider: github'));
@ -69,6 +70,7 @@ void main() {
await configFile.writeAsString(result.stdout); await configFile.writeAsString(result.stdout);
final config = await AppConfig.load(configFile.path); final config = await AppConfig.load(configFile.path);
expect(config.projects['sample'], isNotNull); expect(config.projects['sample'], isNotNull);
expect(config.defaults.maxResponsesAfterHuman, 1);
}); });
/// ```gherkin /// ```gherkin
@ -110,10 +112,12 @@ void main() {
expect(contents, contains('defaults:')); expect(contents, contains('defaults:'));
expect(contents, contains('issueTracker:')); expect(contents, contains('issueTracker:'));
expect(contents, contains('provider: github')); expect(contents, contains('provider: github'));
expect(contents, contains('# maxResponsesAfterHuman: 1'));
expect(contents, contains('scm:')); expect(contents, contains('scm:'));
expect(contents, contains('projects:')); expect(contents, contains('projects:'));
final config = await AppConfig.load(outputFile.path); final config = await AppConfig.load(outputFile.path);
expect(config.projects['sample'], isNotNull); expect(config.projects['sample'], isNotNull);
expect(config.defaults.maxResponsesAfterHuman, 1);
}); });
}); });
} }

View File

@ -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 /// ```gherkin
/// Scenario: Log raw responder output for debugging /// Scenario: Log raw responder output for debugging
/// Given a temporary project checkout that can be used as the local repo path /// Given a temporary project checkout that can be used as the local repo path

View File

@ -135,6 +135,7 @@ ProjectConfig _project({
reconcileInterval: const Duration(seconds: 30), reconcileInterval: const Duration(seconds: 30),
), ),
maxConcurrentIssues: 3, maxConcurrentIssues: 3,
maxResponsesAfterHuman: 1,
mentionTriggers: const ['@helper'], mentionTriggers: const ['@helper'],
prompt: defaultIssueAssistantPrompt, prompt: defaultIssueAssistantPrompt,
responders: const <CliResponderConfig>[], responders: const <CliResponderConfig>[],

View File

@ -82,6 +82,7 @@ void main() {
pollInterval: Duration(seconds: 30), pollInterval: Duration(seconds: 30),
), ),
maxConcurrentIssues: 3, maxConcurrentIssues: 3,
maxResponsesAfterHuman: 1,
mentionTriggers: const ['@helper'], mentionTriggers: const ['@helper'],
prompt: defaultIssueAssistantPrompt, prompt: defaultIssueAssistantPrompt,
responders: const <CliResponderConfig>[], responders: const <CliResponderConfig>[],
@ -135,6 +136,7 @@ void main() {
pollInterval: Duration(seconds: 30), pollInterval: Duration(seconds: 30),
), ),
maxConcurrentIssues: 3, maxConcurrentIssues: 3,
maxResponsesAfterHuman: 1,
mentionTriggers: const ['@helper'], mentionTriggers: const ['@helper'],
prompt: defaultIssueAssistantPrompt, prompt: defaultIssueAssistantPrompt,
responders: const <CliResponderConfig>[], responders: const <CliResponderConfig>[],

View File

@ -17,7 +17,7 @@ void registerIssueAssistantAppRunOnceWorktreeTests() {
/// ``` /// ```
group('IssueAssistantApp.runOnce', () { group('IssueAssistantApp.runOnce', () {
/// ```gherkin /// ```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 /// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing a bug verification mention /// And GitHub issue data containing a bug verification mention
/// And a responder that records its working directory and stdin before returning no_reply /// 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 /// When the app processes one polling cycle
/// Then the responder runs from an ephemeral worktree instead of the source checkout /// 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 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( 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 { () async {
// Given a temporary project checkout that can be used as the local repo path. // Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp( final sandbox = await Directory.systemTemp.createTemp(
@ -128,7 +128,7 @@ projects:
contains('@helper please verify and reproduce this bug'), 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(); final logLines = await ghLog.readAsLines();
expect( expect(
logLines logLines
@ -137,7 +137,7 @@ projects:
line.contains('repos/owner/sample/issues/24/comments'), line.contains('repos/owner/sample/issues/24/comments'),
) )
.length, .length,
2, 1,
); );
}, },
); );