Skip to content

Commit 525b30f

Browse files
authored
feat(test-classifier): stream the agent's steps live on local runs (#55)
A local `test-classifier` run showed only a blinking cursor while the agent worked — `claude -p` is captured into a variable for parsing, so nothing reached the terminal until the whole run finished. Now, on a local interactive run, invoke claude with `--output-format stream-json --verbose` and split the NDJSON stream: each assistant reasoning line (⏺) and tool call (⏎) is narrated to STDERR as it happens, while ONLY the final result text goes to STDOUT — so the dispatcher's marker/JSON parse is byte-identical to the plain `-p` path. Gated so CI is unaffected (should_stream): streams only when stdout is a TTY, not CI, python3 is present, and the user hasn't set AI_REVIEW_STREAM=0. Any of those false → the original silent, captured path. `< /dev/null` avoids the `-p` "no stdin in 3s" warning when piping. Scope: claude only (its stream-json schema is verified); codex/copilot keep the existing captured path. Documented in LOCAL_TEST_CLASSIFIER.md with the AI_REVIEW_STREAM=0 opt-out.
1 parent d04bdd3 commit 525b30f

2 files changed

Lines changed: 99 additions & 8 deletions

File tree

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

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,64 @@ ai_review::timeout_prefix() {
341341
# are the remaining backstops.
342342
}
343343

344+
# ── Live progress streaming (local interactive runs only) ───────────────────
345+
# By default `claude -p` is captured into a variable for parsing, so the user
346+
# sees nothing — a blinking cursor — until the whole run finishes. On a local
347+
# interactive run we instead ask claude for its event stream
348+
# (--output-format stream-json --verbose), narrate each step to STDERR (the
349+
# terminal), and emit ONLY the final result text to STDOUT so the dispatcher's
350+
# marker/JSON parse is byte-identical to the plain `-p` path.
351+
#
352+
# Gated so CI is unaffected: stream only when stdout is a TTY, not CI, the user
353+
# hasn't opted out (AI_REVIEW_STREAM=0), and python3 is present to split the
354+
# stream. Otherwise fall back to plain `-p`.
355+
ai_review::should_stream() {
356+
[[ "${AI_REVIEW_STREAM:-1}" != "0" ]] || return 1
357+
[[ -t 1 ]] || return 1
358+
[[ "${CI:-}" != "true" ]] || return 1
359+
command -v python3 &>/dev/null || return 1
360+
return 0
361+
}
362+
363+
# Reads claude stream-json (NDJSON) on stdin. Narrates assistant text + tool
364+
# calls to STDERR; prints the final result text to STDOUT. Keeps the contract:
365+
# STDOUT carries exactly the agent's final answer (markers + JSON intact).
366+
ai_review::stream_split() {
367+
python3 -c '
368+
import sys, json
369+
def w(s): # progress → stderr, flushed so it appears live
370+
sys.stderr.write(s + "\n"); sys.stderr.flush()
371+
final = ""
372+
for line in sys.stdin:
373+
line = line.strip()
374+
if not line:
375+
continue
376+
try:
377+
e = json.loads(line)
378+
except Exception:
379+
continue
380+
t = e.get("type")
381+
if t == "assistant":
382+
for blk in e.get("message", {}).get("content", []):
383+
bt = blk.get("type")
384+
if bt == "text":
385+
txt = (blk.get("text") or "").strip()
386+
if txt:
387+
w(" ⏺ " + txt)
388+
elif bt == "tool_use":
389+
inp = blk.get("input", {}) or {}
390+
arg = inp.get("command") or inp.get("file_path") or inp.get("pattern") or inp.get("description") or ""
391+
arg = str(arg).replace("\n", " ")
392+
if len(arg) > 80:
393+
arg = arg[:77] + "..."
394+
w(" ⏎ " + str(blk.get("name")) + (" " + arg if arg else ""))
395+
elif t == "result":
396+
final = e.get("result") or ""
397+
# emit the final answer on stdout for the dispatcher to parse
398+
sys.stdout.write(final)
399+
'
400+
}
401+
344402
ai_review::invoke_claude() {
345403
ai_review::require_cli "claude" \
346404
"Install Claude Code: npm install -g @anthropic-ai/claude-code"
@@ -354,21 +412,49 @@ ai_review::invoke_claude() {
354412
# span/metric exporter defaults to `none` (its console form writes to STDOUT
355413
# and would corrupt this parsed result); the run is captured via
356414
# OTEL_LOG_RAW_API_BODIES file output instead. So stdout here stays clean.
415+
# On a local interactive run, stream the agent's steps live (see
416+
# should_stream / stream_split): --output-format stream-json --verbose emits
417+
# NDJSON events that stream_split narrates to STDERR while forwarding only the
418+
# final result text to STDOUT — so the captured stdout (and its marker/JSON
419+
# parse) is identical to the plain `-p` path. CI keeps the silent, clean path.
420+
local stream=0
421+
if ai_review::should_stream; then
422+
stream=1
423+
ai_review::info "Streaming the agent's steps below (set AI_REVIEW_STREAM=0 to silence)…" >&2
424+
fi
425+
357426
if (( AI_RUN_SUITE == 1 )); then
358427
# Grant execution so the agent can install deps + run the suite headlessly.
359428
# Headless `claude -p` HANGS on any Bash call without a permission grant, so
360429
# this flag set is required for suite-running, not optional. --allowedTools
361430
# scopes it to exactly what the task needs; --max-turns bounds the loop.
362431
#
363-
# NOTE: no 2>&1 here — keep the agent's stderr diagnostics OUT of the
364-
# captured stdout so they can't corrupt the JSON/marker parse. stderr still
365-
# flows to the CI log.
366-
$(ai_review::timeout_prefix) claude -p "${SKILL_PROMPT}" \
367-
--permission-mode bypassPermissions \
368-
--allowedTools "Bash,Read,Edit" \
369-
--max-turns "${AI_SUITE_MAX_TURNS}"
432+
# NOTE: no 2>&1 — keep the agent's stderr diagnostics OUT of the captured
433+
# stdout so they can't corrupt the JSON/marker parse. stderr still flows to
434+
# the terminal / CI log.
435+
if (( stream == 1 )); then
436+
# < /dev/null: `-p` otherwise waits on stdin (the pipe keeps it open) and
437+
# warns "no stdin data received in 3s". We pass the prompt as an arg, so
438+
# there is no stdin to read.
439+
$(ai_review::timeout_prefix) claude -p "${SKILL_PROMPT}" \
440+
--permission-mode bypassPermissions \
441+
--allowedTools "Bash,Read,Edit" \
442+
--max-turns "${AI_SUITE_MAX_TURNS}" \
443+
--output-format stream-json --verbose < /dev/null \
444+
| ai_review::stream_split
445+
else
446+
$(ai_review::timeout_prefix) claude -p "${SKILL_PROMPT}" \
447+
--permission-mode bypassPermissions \
448+
--allowedTools "Bash,Read,Edit" \
449+
--max-turns "${AI_SUITE_MAX_TURNS}"
450+
fi
370451
else
371-
claude -p "${SKILL_PROMPT}" 2>&1
452+
if (( stream == 1 )); then
453+
claude -p "${SKILL_PROMPT}" --output-format stream-json --verbose < /dev/null \
454+
| ai_review::stream_split
455+
else
456+
claude -p "${SKILL_PROMPT}" 2>&1
457+
fi
372458
fi
373459
}
374460

testing/classifier/docs/LOCAL_TEST_CLASSIFIER.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ AI_RUN_SUITE=1 test-classifier # run the suite locally and triage REAL fa
123123
runs your suite (OBSERVED) — the only mode in which `FLAKY_FAILURE` /
124124
`ENVIRONMENT_ISSUE` are reliably reachable, since you can't see a timeout or
125125
non-determinism from a diff.
126+
- **Live progress.** On a local interactive run the dispatcher streams the
127+
agent's steps (each `` reasoning line and `` tool call) to your terminal as
128+
they happen, so it isn't a blinking cursor while it works. The final report is
129+
unchanged. To silence the step stream (just wait for the report), set
130+
`AI_REVIEW_STREAM=0`. In CI the run is always silent until it finishes.
126131

127132
---
128133

0 commit comments

Comments
 (0)