Skip to content

Commit 92559d4

Browse files
authored
feat(test-classifier): stream agent steps for codex and copilot too (not just claude) (#72)
Live step streaming (⏺/⏎ to the terminal) was claude-only — invoke_codex and invoke_copilot ignored AI_REVIEW_DO_STREAM and ran quiet, so a copilot/codex user saw a frozen cursor then only the final report. Now all three stream, each via the mechanism its CLI actually provides: • codex: 'codex exec --json' emits a JSONL event stream; new codex_stream_split narrates command_execution/agent_message/reasoning items to STDERR and emits only the final agent message to STDOUT (same parse contract as claude). • copilot: the Copilot CLI has NO structured output for -p mode (github/copilot-cli#52, open). Use the supported hooks API instead: a preToolUse command hook fires before each tool call in -p mode and gets the call as JSON on stdin {timestamp,cwd,toolName,toolArgs}. The hook echoes '⏎ toolName toolArgs' to STDERR and returns '{}' so copilot proceeds. Hooks are global-only, so the dispatcher points COPILOT_HOME at a throwaway dir for the run and removes it after — nothing touches the user's real ~/.copilot. claude path unchanged. Streaming still gated by should_stream (TTY + not CI + python3 + not opted out); quiet fallback otherwise. Tested (CLIs not installed here, so the in-tool run is unverified — see below): • codex_stream_split on sample --json events → correct ⏺/⏎ + final-only stdout. • copilot hook script on sample preToolUse payloads (string args, object args, long-truncation, malformed) → narrates to stderr, returns {}, never crashes. • bash -n clean; claude path byte-identical.
1 parent bbf58ca commit 92559d4

2 files changed

Lines changed: 146 additions & 6 deletions

File tree

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

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,54 @@ sys.stdout.write(final)
410410
'
411411
}
412412

413+
# Reads `codex exec --json` JSONL events on stdin. Same contract as stream_split:
414+
# narrate steps to STDERR, emit ONLY the final agent message to STDOUT (markers +
415+
# JSON intact) for the dispatcher to parse. Codex item types we surface:
416+
# command_execution → ⏎ (a shell/tool call)
417+
# agent_message / reasoning → ⏺ (the model's text/thinking)
418+
# The final answer is the last agent_message item (codex does not emit a single
419+
# "result" event; the concluding agent_message IS the answer).
420+
ai_review::codex_stream_split() {
421+
python3 -c '
422+
import sys, json
423+
def w(s):
424+
sys.stderr.write(s + "\n"); sys.stderr.flush()
425+
final = ""
426+
for line in sys.stdin:
427+
line = line.strip()
428+
if not line:
429+
continue
430+
try:
431+
e = json.loads(line)
432+
except Exception:
433+
continue
434+
# Codex nests the work unit under "item" on item.* events.
435+
item = e.get("item") or e
436+
it = item.get("item_type") or item.get("type") or e.get("type") or ""
437+
if it in ("agent_message", "assistant_message", "message"):
438+
txt = (item.get("text") or item.get("message") or "").strip()
439+
if txt:
440+
w(" ⏺ " + (txt if len(txt) <= 200 else txt[:197] + "..."))
441+
final = txt # the latest agent message is the running final answer
442+
elif it == "reasoning":
443+
txt = (item.get("text") or "").strip()
444+
if txt:
445+
w(" ⏺ " + (txt if len(txt) <= 200 else txt[:197] + "..."))
446+
elif it in ("command_execution", "command", "exec"):
447+
cmd = (item.get("command") or item.get("cmd") or "").replace("\n", " ")
448+
if len(cmd) > 80:
449+
cmd = cmd[:77] + "..."
450+
if cmd:
451+
w(" ⏎ command " + cmd)
452+
elif it == "file_change":
453+
path = item.get("path") or item.get("file") or ""
454+
if path:
455+
w(" ⏎ file_change " + str(path))
456+
# emit the final agent message on stdout for the dispatcher to parse
457+
sys.stdout.write(final)
458+
'
459+
}
460+
413461
ai_review::invoke_claude() {
414462
ai_review::require_cli "claude" \
415463
"Install Claude Code: npm install -g @anthropic-ai/claude-code"
@@ -478,10 +526,26 @@ ai_review::invoke_codex() {
478526
# read-only mode: filesystem read access only (git diff / file reads), no
479527
# writes or network — the triage-only default.
480528
# suite mode: workspace-write so it can install deps and run tests.
481-
if (( AI_RUN_SUITE == 1 )); then
482-
$(ai_review::timeout_prefix) codex exec --sandbox workspace-write --skip-git-repo-check "${SKILL_PROMPT}" 2>&1
529+
#
530+
# Streaming: `codex exec --json` makes stdout a JSONL event stream; we pipe it
531+
# through codex_stream_split, which narrates steps to STDERR and emits ONLY the
532+
# final agent message to STDOUT — so the captured stdout stays parseable, same
533+
# contract as the plain path. Without streaming we omit --json so stdout is the
534+
# plain final message directly (no splitter needed). NOTE: no 2>&1 in the
535+
# streaming branch — codex's own stderr stays on the terminal and must not be
536+
# folded into the captured stdout that gets parsed.
537+
local stream="${AI_REVIEW_DO_STREAM:-0}"
538+
local sandbox="read-only"
539+
(( AI_RUN_SUITE == 1 )) && sandbox="workspace-write"
540+
541+
if (( stream == 1 )); then
542+
ai_review::info "Streaming the agent's steps below (set AI_REVIEW_STREAM=0 to silence)…" >&2
543+
$(ai_review::timeout_prefix) codex exec --json --sandbox "${sandbox}" \
544+
--skip-git-repo-check "${SKILL_PROMPT}" \
545+
| ai_review::codex_stream_split
483546
else
484-
codex exec --sandbox read-only --skip-git-repo-check "${SKILL_PROMPT}" 2>&1
547+
$(ai_review::timeout_prefix) codex exec --sandbox "${sandbox}" \
548+
--skip-git-repo-check "${SKILL_PROMPT}" 2>&1
485549
fi
486550
}
487551

@@ -493,10 +557,77 @@ ai_review::invoke_copilot() {
493557
# suite mode: --allow-all-tools lets it run install/test commands headlessly;
494558
# -s suppresses stats/decoration for clean scriptable output. Copilot has no
495559
# built-in turn/timeout cap, so the timeout wrapper is the only bound.
496-
if (( AI_RUN_SUITE == 1 )); then
497-
$(ai_review::timeout_prefix) copilot -p "${SKILL_PROMPT}" --allow-all-tools -s 2>&1
560+
#
561+
# Streaming: the Copilot CLI has NO structured/JSON event output for -p mode
562+
# (github/copilot-cli#52, still open), so we can't split its stdout the way we
563+
# do for claude/codex. Instead we use the SUPPORTED hooks API: a preToolUse
564+
# hook fires before each tool call in -p mode and receives the call as JSON on
565+
# stdin ({timestamp,cwd,toolName,toolArgs}). Our hook echoes that to STDERR
566+
# (live ⏎ narration) and returns "{}" on stdout so copilot proceeds unchanged.
567+
# Hooks are configured globally under $COPILOT_HOME/hooks/, so we point
568+
# COPILOT_HOME at a throwaway dir for this run and remove it after — nothing is
569+
# left in the user's real ~/.copilot.
570+
local stream="${AI_REVIEW_DO_STREAM:-0}"
571+
local copilot_flags=()
572+
(( AI_RUN_SUITE == 1 )) && copilot_flags+=(--allow-all-tools)
573+
574+
if (( stream == 1 )); then
575+
ai_review::info "Streaming the agent's steps below (set AI_REVIEW_STREAM=0 to silence)…" >&2
576+
# Throwaway COPILOT_HOME so the streaming hook is scoped to this run only.
577+
local cphome
578+
cphome="$(mktemp -d "${TMPDIR:-/tmp}/tc-copilot-home.XXXXXX")"
579+
mkdir -p "${cphome}/hooks"
580+
# The hook script: read the preToolUse payload on stdin, narrate to stderr,
581+
# return an empty object so copilot runs the tool unchanged.
582+
cat > "${cphome}/hooks/stream.sh" <<'HOOK'
583+
#!/usr/bin/env bash
584+
# preToolUse hook: copilot pipes the call payload on stdin; we narrate it to the
585+
# terminal (stderr) and return {} on stdout so copilot runs the tool unchanged.
586+
# Do NOT redirect python's stderr — that IS where the narration goes. All errors
587+
# are swallowed INSIDE python so a parse failure can't break the run.
588+
payload="$(cat)"
589+
python3 -c '
590+
import sys, json
591+
try:
592+
e = json.loads(sys.argv[1] or "{}")
593+
name = e.get("toolName") or "tool"
594+
args = e.get("toolArgs") or ""
595+
if not isinstance(args, str):
596+
args = json.dumps(args)
597+
args = args.replace("\n", " ")
598+
if len(args) > 80:
599+
args = args[:77] + "..."
600+
sys.stderr.write(" ⏎ " + str(name) + ((" " + args) if args else "") + "\n")
601+
sys.stderr.flush()
602+
except Exception:
603+
pass
604+
' "$payload" || true
605+
printf '{}'
606+
HOOK
607+
chmod +x "${cphome}/hooks/stream.sh"
608+
# Register the preToolUse command hook.
609+
cat > "${cphome}/hooks/hooks.json" <<HOOKCFG
610+
{
611+
"preToolUse": [
612+
{ "type": "command", "bash": "${cphome}/hooks/stream.sh", "timeoutSec": 10 }
613+
]
614+
}
615+
HOOKCFG
616+
# Run copilot with COPILOT_HOME pointed at the throwaway dir; clean up after.
617+
# NO 2>&1 here: the hook narrates to STDERR (which must flow to the terminal,
618+
# not into the captured stdout we parse). -s keeps stdout to the clean final
619+
# answer. The hook's ⏎ lines + copilot's own stderr stay on the terminal.
620+
COPILOT_HOME="${cphome}" $(ai_review::timeout_prefix) \
621+
copilot -p "${SKILL_PROMPT}" "${copilot_flags[@]+"${copilot_flags[@]}"}" -s
622+
local rc=$?
623+
rm -rf "${cphome}"
624+
return $rc
498625
else
499-
copilot -p "${SKILL_PROMPT}" 2>&1
626+
if (( AI_RUN_SUITE == 1 )); then
627+
$(ai_review::timeout_prefix) copilot -p "${SKILL_PROMPT}" --allow-all-tools -s 2>&1
628+
else
629+
copilot -p "${SKILL_PROMPT}" 2>&1
630+
fi
500631
fi
501632
}
502633

testing/classifier/docs/LOCAL_TEST_CLASSIFIER.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,15 @@ The row lands in the **Testing Events** tab by default; override with
211211
unchanged. To silence the step stream (just wait for the report), set
212212
`AI_REVIEW_STREAM=0`. In CI the run is always silent until it finishes.
213213

214+
Streaming works for **all three tools**, by the means each CLI provides:
215+
`claude` via `--output-format stream-json`; `codex` via `codex exec --json`;
216+
and `copilot` via a `preToolUse` hook (the Copilot CLI has no structured
217+
output mode yet — see [github/copilot-cli#52](https://github.com/github/copilot-cli/issues/52)
218+
so the dispatcher installs a temporary hook under a throwaway `COPILOT_HOME`
219+
that narrates each tool call, and removes it when the run ends). Streaming
220+
needs `python3` on PATH and a real terminal; without either, the run falls
221+
back to quiet mode and prints only the final report.
222+
214223
---
215224

216225
## When to run

0 commit comments

Comments
 (0)