Skip to content

Commit 6c8594a

Browse files
committed
feat(test-classifier): post + record true negatives; add --simulate; steer observed mode to narrow targets
Three changes on top of the current dispatcher, from pilot feedback: - Post AND record true negatives. A NO_ACTION result now posts a "no action required" PR comment (carrying the 👍/👎 ask) instead of staying silent, and auto-submit/--submit writes a Testing Events row for it (verdict NO_ACTION). Previously a true negative was invisible to both the PR and the metrics sheet, so the tuning loop never saw "agent ran, nothing to triage" outcomes. - Add --simulate: skip the agent and feed synthetic output through the real posting/metrics path, so the comment + reaction + Testing Events pipeline can be validated end-to-end without a live (slow/costly) agent run. Set AI_SIMULATE_RESULT=NO_ACTION to exercise the true-negative path. - Steer OBSERVED mode to run the narrowest test target (changed module/package) rather than a full multi-module build, to avoid the reactor-build timeouts seen on large repos. Also ignore test-repos/ (cloned fixtures for exercising the classifier).
1 parent e933f13 commit 6c8594a

3 files changed

Lines changed: 156 additions & 47 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
.claude/settings.local.json
22
.env.local
33
.env
4+
5+
# Cloned test repos (fixtures for exercising the classifier) — never commit
6+
test-repos/

testing/classifier/.skills/_lib/ai-classifier-dispatch.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@ Usage:
201201
Options:
202202
-n, --dry-run Print the resolved AI tool, prompt, and target files,
203203
but do not invoke the AI. Exits 0.
204+
--simulate Skip the AI agent and feed a synthetic classifier result
205+
through the real posting/metrics path. Use with
206+
--post-comment / --submit to validate the comment + 👍/👎
207+
+ Testing Events pipeline without a live (slow/costly)
208+
agent run. Set AI_SIMULATE_RESULT=NO_ACTION to exercise
209+
the true-negative path; defaults to a CLASSIFIED filler.
204210
--no-block Run the full classification but always exit 0, regardless
205211
of the result marker (the classifier is advisory anyway;
206212
this is belt-and-suspenders for CI experiments).

testing/classifier/.skills/test-classifier/scripts/test-classifier-dispatcher.sh

Lines changed: 147 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ GATE_MODE=0
117117
JSON_ONLY=0
118118
WANT_HELP=0
119119
SUBMIT=0
120+
SIMULATE=0
120121
POSTED_COMMENT_ID="" # set by the post functions; consumed by --submit
121122
POSTED_COMMENT_CREATED="" # the comment's created_at (ISO-8601) from the API
122123
REMAINING_FOR_LIB=()
@@ -165,6 +166,16 @@ while [[ $# -gt 0 ]]; do
165166
JSON_ONLY=1
166167
shift
167168
;;
169+
--simulate)
170+
# Skip the AI agent entirely and feed a synthetic classifier result
171+
# through the real posting/metrics path — validate the comment + 👍/👎 +
172+
# Testing Events pipeline without a live (slow/costly) agent run. Pair with
173+
# --post-comment / --submit to actually post + record. Set
174+
# AI_SIMULATE_RESULT=NO_ACTION to exercise the true-negative path;
175+
# defaults to a CLASSIFIED filler.
176+
SIMULATE=1
177+
shift
178+
;;
168179
-h|--help)
169180
# Defer: PR discovery runs before the lib's parser, so a bare --help left
170181
# for the lib would error on PR lookup before reaching the help text. We
@@ -396,10 +407,24 @@ if [[ "${AI_RUN_SUITE:-1}" == "1" ]]; then
396407
shell execution): LOCATE this repo's test command (package.json scripts,
397408
Makefile, pytest/tox, go.mod, Cargo.toml, or its CI workflow's test step),
398409
INSTALL deps from the repo's lockfile (best-effort), and RUN the suite to
399-
get the real pass/fail output. Set "mode":"OBSERVED" in the JSON. If you
400-
cannot locate/install/run it (no suite, missing toolchain, needs services,
401-
times out), fall back to predicting from the diff, set "mode":"INFERRED",
402-
and state the reason in "summary".
410+
get the real pass/fail output.
411+
412+
RUN THE NARROWEST TEST TARGET, NOT A FULL BUILD. You are time-bounded. Do
413+
NOT trigger a whole-project build when a targeted test run exists — building
414+
everything before the suite is the single biggest cause of timeouts.
415+
- Multi-module Maven/Gradle: run tests for the changed module(s) only,
416+
e.g. `mvn -pl <changed-module> -am test` (or `-Dtest=...` for specific
417+
classes), or `gradle :<module>:test`. Do NOT run the root reactor build
418+
(`mvn test` / `mvn verify` at the root). Skip unrelated lifecycle phases
419+
where possible (`-DskipITs`, `-Dcheckstyle.skip`) so time goes to tests.
420+
- Other ecosystems: scope to the changed package / path (e.g.
421+
`pytest path/to/changed_tests`, `go test ./changedpkg/...`,
422+
`npm test -- <pattern>`) rather than the entire suite when the diff is
423+
local.
424+
Set "mode":"OBSERVED" in the JSON. If you cannot locate/install/run it (no
425+
suite, missing toolchain, needs services, times out), OR the only available
426+
path is a full build that would not finish in time, fall back to predicting
427+
from the diff, set "mode":"INFERRED", and state the reason in "summary".
403428
404429
BUDGET DISCIPLINE — you have a bounded number of agentic turns; a turn is
405430
one assistant iteration, not one tool call, so batch independent lookups
@@ -671,14 +696,39 @@ lines = []
671696
# IMPORTANT: this anchor line must stay byte-identical — the banner goes AFTER it.
672697
lines.append("test-classifier: AI triage of failing tests")
673698
lines.append("")
674-
lines.append("## AI Test Classifier — triage of failing tests")
675-
lines.append("")
676-
# Signal-provenance banner so a prediction is never mistaken for a real run.
699+
700+
# Signal-provenance banner (shared by both branches) so a prediction is never
701+
# mistaken for a real run.
677702
if mode == "OBSERVED":
678-
lines.append("> **Observed** — these verdicts are grounded in the actual test run output.")
703+
banner = "> **Observed** — these verdicts are grounded in the actual test run output."
679704
else:
680-
lines.append("> **Inferred, not observed** — the suite was not run for this triage, so these "
681-
"verdicts are predicted from the diff. See the summary for why.")
705+
banner = ("> **Inferred, not observed** — the suite was not run for this triage, so these "
706+
"verdicts are predicted from the diff. See the summary for why.")
707+
708+
# True-negative branch: the agent ran and found nothing to triage. We STILL post
709+
# a comment (not silence) so the developer can 👍/👎 it — that reaction is the
710+
# only tuning signal a true negative can produce, and the metrics harvester needs
711+
# a comment to attach the reaction to. Keep the same anchor line above so the
712+
# harvester matches it by the `test-classifier:` prefix.
713+
if not classifications:
714+
lines.append("## AI Test Classifier — no action required")
715+
lines.append("")
716+
lines.append(banner)
717+
lines.append("")
718+
lines.append(summary or "No failing tests were triaged for the change under test.")
719+
lines.append("")
720+
if want_reaction_ask:
721+
lines.append("**React 👍 if this is right (nothing needed triage) / 👎 if a real "
722+
"failure was missed**, and on a 👎 please **reply with a one-line "
723+
"reason**. Advisory, non-blocking.")
724+
else:
725+
lines.append("_Advisory, non-blocking — diagnostic only; the classifier never edits code or tests._")
726+
print("\n".join(lines))
727+
sys.exit(0)
728+
729+
lines.append("## AI Test Classifier — triage of failing tests")
730+
lines.append("")
731+
lines.append(banner)
682732
lines.append("")
683733
lines.append(summary)
684734
lines.append("")
@@ -949,6 +999,13 @@ print("\t".join([best.get("verdict","") or "", best.get("category","") or "", be
949999
local verdict category confidence
9501000
IFS=$'\t' read -r verdict category confidence <<< "${repr}"
9511001

1002+
# True negative: no classifications, so `repr` is empty. Record an explicit
1003+
# NO_ACTION verdict rather than a blank row so the sheet distinguishes "agent
1004+
# ran, nothing to triage" (a real, countable true negative) from a missing row.
1005+
if [[ -z "${verdict}" ]]; then
1006+
verdict="NO_ACTION"
1007+
fi
1008+
9521009
# comment_created_at comes from the comment POST response; fall back to now.
9531010
local created_at="${POSTED_COMMENT_CREATED:-}"
9541011
[[ -n "${created_at}" ]] || created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")"
@@ -993,6 +1050,40 @@ print(json.dumps(dict(zip(keys, sys.argv[1:1+len(keys)]))))
9931050
fi
9941051
}
9951052

1053+
# Synthesize a classifier output for --simulate mode, byte-compatible with what
1054+
# a real agent emits: the fenced JSON block plus the trailing result marker. This
1055+
# lets the full downstream path (parse_result → extract_classifier_json →
1056+
# render_pr_comment_body → post_comment_to_github → submit_metrics_row) run
1057+
# without invoking the agent. Set AI_SIMULATE_RESULT=NO_ACTION to exercise the
1058+
# true-negative comment + row instead of the default CLASSIFIED table.
1059+
ai_review::synthetic_output() {
1060+
local kind="${AI_SIMULATE_RESULT:-CLASSIFIED}"
1061+
local mode="INFERRED"
1062+
(( AI_RUN_SUITE == 1 )) && mode="OBSERVED"
1063+
1064+
if [[ "${kind}" == "NO_ACTION" ]]; then
1065+
cat <<SIM
1066+
[SIMULATED OUTPUT — no agent was invoked (--simulate).]
1067+
1068+
<!-- AI_CLASSIFIER_JSON_BEGIN -->
1069+
{ "mode": "${mode}", "summary": "SIMULATED: no failing tests were triaged (pipeline dry run).", "classifications": [] }
1070+
<!-- AI_CLASSIFIER_JSON_END -->
1071+
1072+
<<<AI_REVIEW_RESULT:NO_ACTION>>>
1073+
SIM
1074+
else
1075+
cat <<SIM
1076+
[SIMULATED OUTPUT — no agent was invoked (--simulate).]
1077+
1078+
<!-- AI_CLASSIFIER_JSON_BEGIN -->
1079+
{ "mode": "${mode}", "summary": "SIMULATED: filler triage for pipeline dry run (these verdicts are not real).", "classifications": [ { "verdict": "FLAKY_FAILURE", "test": "simulated::filler_test", "path": "SIMULATED", "line": 0, "category": "other", "confidence": "low", "in_scope": true, "rationale": "Synthetic entry produced by --simulate to exercise the posting/metrics path without a live agent run." } ] }
1080+
<!-- AI_CLASSIFIER_JSON_END -->
1081+
1082+
<<<AI_REVIEW_RESULT:CLASSIFIED>>>
1083+
SIM
1084+
fi
1085+
}
1086+
9961087
# ── Custom run loop (mirrors the security PR dispatcher) ────────────────────
9971088
test_classifier::run() {
9981089
# Discover the PR (and inject --against into REMAINING_FOR_LIB).
@@ -1006,12 +1097,15 @@ test_classifier::run() {
10061097
exit 0
10071098
fi
10081099

1009-
ai_review::resolve_tool
1100+
# --simulate skips the agent, so it does not need a resolved/installed AI CLI.
1101+
if (( SIMULATE == 0 )); then
1102+
ai_review::resolve_tool
1103+
fi
10101104

10111105
if (( AI_REVIEW_DRY_RUN == 1 )); then
10121106
ai_review::info "DRY-RUN — no AI invocation will be made."
10131107
ai_review::log " Skill: ${SKILL_HUMAN_NAME} (${SKILL_NAME})"
1014-
ai_review::log " AI tool: ${AI_REVIEW_TOOL_RESOLVED}"
1108+
ai_review::log " AI tool: ${AI_REVIEW_TOOL_RESOLVED:-(skipped — --simulate)}"
10151109
ai_review::log " PR number: ${AI_REVIEW_PR_NUMBER:-(none — using --against directly)}"
10161110
ai_review::log " Diff source: $(ai_review::diff_command_description)"
10171111
ai_review::log " Post comment: ${POST_COMMENT}"
@@ -1022,9 +1116,6 @@ test_classifier::run() {
10221116
exit 0
10231117
fi
10241118

1025-
ai_review::info "Running ${SKILL_HUMAN_NAME} on $(ai_review::diff_command_description) via ${AI_REVIEW_TOOL_RESOLVED}..."
1026-
ai_review::log "────────────────────────────────────────────────────────────"
1027-
10281119
export AI_REVIEW_AGAINST
10291120
# Export the PR context so the agent can re-resolve the diff itself when the
10301121
# precomputed range below is empty/wrong (forks, enterprise hosts, stale
@@ -1047,7 +1138,15 @@ test_classifier::run() {
10471138

10481139
local classifier_output
10491140
local invoke_rc=0
1050-
classifier_output="$(ai_review::invoke_ai)" || invoke_rc=$?
1141+
if (( SIMULATE == 1 )); then
1142+
ai_review::info "SIMULATE — skipping the AI agent; feeding synthetic output (${AI_SIMULATE_RESULT:-CLASSIFIED}) through the real posting/metrics path."
1143+
ai_review::log "────────────────────────────────────────────────────────────"
1144+
classifier_output="$(ai_review::synthetic_output)"
1145+
else
1146+
ai_review::info "Running ${SKILL_HUMAN_NAME} on $(ai_review::diff_command_description) via ${AI_REVIEW_TOOL_RESOLVED}..."
1147+
ai_review::log "────────────────────────────────────────────────────────────"
1148+
classifier_output="$(ai_review::invoke_ai)" || invoke_rc=$?
1149+
fi
10511150

10521151
if (( JSON_ONLY == 1 )); then
10531152
extract_classifier_json "${classifier_output}"
@@ -1107,51 +1206,52 @@ test_classifier::run() {
11071206
local auto_submit=0
11081207
if [[ -n "${METRICSAI_WEBHOOK_URL:-}" && -n "${METRICSAI_WEBHOOK_KEY:-}" ]] \
11091208
&& [[ "${CI:-}" != "true" ]] && { : <>/dev/tty; } 2>/dev/null \
1110-
&& [[ "${result}" == "CLASSIFIED" ]]; then
1209+
&& { [[ "${result}" == "CLASSIFIED" ]] || [[ "${result}" == "NO_ACTION" ]]; }; then
11111210
auto_submit=1
11121211
fi
11131212
local do_submit=0
11141213
(( SUBMIT == 1 || auto_submit == 1 )) && do_submit=1
11151214

11161215
# ── Post ONE PR comment with the verdicts. ────────────────────────────────
11171216
# --post-comment is what CI passes to actually post; omit it for a local dry
1118-
# view (the JSON/report still prints to stdout). Nothing is posted when nothing
1119-
# was triaged (NO_ACTION). The comment carries the 👍/👎 reaction ask on a
1120-
# CI/--post-comment run; when we capture the signal via the terminal prompt
1121-
# (--submit or auto-submit) the ask is omitted (the prompt is the signal).
1217+
# view (the JSON/report still prints to stdout). A comment is posted on BOTH
1218+
# results: CLASSIFIED (the verdict table) AND NO_ACTION (a "no action required"
1219+
# comment). The true negative is posted so its 👍/👎 reaction can be harvested
1220+
# by the metrics loop — suppressing it (the earlier behavior) made true
1221+
# negatives invisible to tuning. render_pr_comment_body renders a dedicated
1222+
# "no action required" body when classifications is empty. The comment carries
1223+
# the 👍/👎 reaction ask on a CI/--post-comment run; when we capture the signal
1224+
# via the terminal prompt (--submit or auto-submit) the ask is omitted.
11221225
if (( POST_COMMENT == 1 )); then
1123-
if [[ "${result}" == "NO_ACTION" ]]; then
1124-
ai_review::info "Result is NO_ACTION — nothing to triage, so no PR comment is posted."
1125-
else
1126-
if [[ -z "${AI_REVIEW_PR_NUMBER:-}" ]]; then
1127-
ai_review::err "--post-comment requires a discoverable PR. Use --pr <number> or ensure 'gh pr view' resolves. (A --unpushed local run has no PR, so it is report-only — drop --post-comment.)"
1128-
exit 1
1129-
fi
1130-
local json_block
1131-
json_block="$(extract_classifier_json "${classifier_output}")"
1132-
if [[ -z "${json_block}" ]]; then
1133-
ai_review::err "AI response did not contain a parseable JSON block."
1134-
ai_review::log " Expected fenced block bounded by:"
1135-
ai_review::log " <!-- AI_CLASSIFIER_JSON_BEGIN -->"
1136-
ai_review::log " <!-- AI_CLASSIFIER_JSON_END -->"
1137-
exit 1
1138-
fi
1139-
# Include the 👍/👎 reaction ask UNLESS we're capturing the signal via the
1140-
# terminal prompt (--submit or auto-submit). CI / plain --post-comment →
1141-
# ask (1); prompt-capturing run → no ask (0). Both surfaces stay alive.
1142-
local want_reaction_ask=1
1143-
(( do_submit == 1 )) && want_reaction_ask=0
1144-
local comment_body
1145-
comment_body="$(render_pr_comment_body "${json_block}" "${want_reaction_ask}")"
1146-
post_comment_to_github "${AI_REVIEW_PR_NUMBER}" "${comment_body}"
1226+
if [[ -z "${AI_REVIEW_PR_NUMBER:-}" ]]; then
1227+
ai_review::err "--post-comment requires a discoverable PR. Use --pr <number> or ensure 'gh pr view' resolves. (A --unpushed local run has no PR, so it is report-only — drop --post-comment.)"
1228+
exit 1
1229+
fi
1230+
local json_block
1231+
json_block="$(extract_classifier_json "${classifier_output}")"
1232+
if [[ -z "${json_block}" ]]; then
1233+
ai_review::err "AI response did not contain a parseable JSON block."
1234+
ai_review::log " Expected fenced block bounded by:"
1235+
ai_review::log " <!-- AI_CLASSIFIER_JSON_BEGIN -->"
1236+
ai_review::log " <!-- AI_CLASSIFIER_JSON_END -->"
1237+
exit 1
11471238
fi
1239+
# Include the 👍/👎 reaction ask UNLESS we're capturing the signal via the
1240+
# terminal prompt (--submit or auto-submit). CI / plain --post-comment →
1241+
# ask (1); prompt-capturing run → no ask (0). Both surfaces stay alive.
1242+
local want_reaction_ask=1
1243+
(( do_submit == 1 )) && want_reaction_ask=0
1244+
local comment_body
1245+
comment_body="$(render_pr_comment_body "${json_block}" "${want_reaction_ask}")"
1246+
post_comment_to_github "${AI_REVIEW_PR_NUMBER}" "${comment_body}"
11481247
fi
11491248

11501249
# ── Capture the helpfulness signal + write the Testing Events row. ─────────
11511250
# Runs on --submit OR auto-submit, INDEPENDENT of whether a comment was posted:
11521251
# a local report-only run with no PR still prompts and writes a row (with empty
1153-
# comment fields). Guarded internally on TTY + CLASSIFIED; no-ops in CI.
1154-
if (( do_submit == 1 )) && [[ "${result}" == "CLASSIFIED" ]]; then
1252+
# comment fields). Fires on CLASSIFIED and NO_ACTION alike (a true negative is
1253+
# a countable row); submit_metrics_row guards internally on TTY and no-ops in CI.
1254+
if (( do_submit == 1 )) && { [[ "${result}" == "CLASSIFIED" ]] || [[ "${result}" == "NO_ACTION" ]]; }; then
11551255
local json_block_submit
11561256
json_block_submit="$(extract_classifier_json "${classifier_output}")"
11571257
if [[ -n "${json_block_submit}" ]]; then

0 commit comments

Comments
 (0)