From 558db6991c61c0ee3ee18a04205bc1f828db425c Mon Sep 17 00:00:00 2001 From: insleker Date: Sat, 4 Apr 2026 23:03:38 +0800 Subject: [PATCH] fix: issue responder context and mention replies --- README.md | 2 +- build.yaml | 10 ++ examples/simple-github.yaml | 2 +- lib/src/cli_responder.dart | 54 +++++++- lib/src/config.dart | 19 ++- lib/src/issue_assistant_app.dart | 58 ++++++-- test/code_work_spawner_test.dart | 222 ++++++++++++++++++++++++++++++- 7 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 build.yaml diff --git a/README.md b/README.md index ca5b51d..0a8b3a6 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..edf45f8 --- /dev/null +++ b/build.yaml @@ -0,0 +1,10 @@ +targets: + $default: +# sources: + builders: + drift_dev: + generate_for: + - lib/src/database.dart + # json_serializable: + # generate_for: + # - lib/**/model/*.dart diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index 3e61681..c7cf754 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -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 diff --git a/lib/src/cli_responder.dart b/lib/src/cli_responder.dart index e03a977..dea5e6b 100644 --- a/lib/src/cli_responder.dart +++ b/lib/src/cli_responder.dart @@ -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 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 json, { required String responderId, diff --git a/lib/src/config.dart b/lib/src/config.dart index 82f5f2f..a5efd02 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -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}} '''; } diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index a9fd152..7d088fb 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -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'); + } + } } diff --git a/test/code_work_spawner_test.dart b/test/code_work_spawner_test.dart index 799bedf..285fed6 100644 --- a/test/code_work_spawner_test.dart +++ b/test/code_work_spawner_test.dart @@ -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 = []; + 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 _writeFakeGhScript({ Future _writeResponderScript( File responderScript, - Map response, -) async { + Map 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]); }