

























@@ -6,13 +6,15 @@ import concurrent.futures
66import copy
77import json
88import os
9+import queue
910import subprocess
1011import sys
1112import tempfile
1213import textwrap
14+import threading
1315import time
1416from pathlib import Path
15-from typing import Any
17+from typing import Any, Callable
161817191820ENGINES = ("codex", "claude", "droid", "copilot")
@@ -100,7 +102,18 @@ def run_with_heartbeat(
100102input_text: str | None = None,
101103label: str,
102104heartbeat_seconds: int = 60,
105+stream_output: bool = False,
106+stream_display: Callable[[str, str], str | None] | None = None,
103107) -> subprocess.CompletedProcess[str]:
108+if stream_output:
109+return run_with_stream(
110+args,
111+cwd,
112+input_text=input_text,
113+label=label,
114+heartbeat_seconds=heartbeat_seconds,
115+stream_display=stream_display,
116+ )
104117started = time.monotonic()
105118proc = subprocess.Popen(
106119args,
@@ -124,6 +137,82 @@ def run_with_heartbeat(
124137print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True)
125138126139140+def run_with_stream(
141+args: list[str],
142+cwd: Path,
143+*,
144+input_text: str | None,
145+label: str,
146+heartbeat_seconds: int,
147+stream_display: Callable[[str, str], str | None] | None,
148+) -> subprocess.CompletedProcess[str]:
149+started = time.monotonic()
150+proc = subprocess.Popen(
151+args,
152+cwd=cwd,
153+stdin=subprocess.PIPE if input_text is not None else None,
154+stdout=subprocess.PIPE,
155+stderr=subprocess.PIPE,
156+text=True,
157+bufsize=1,
158+ )
159+events: queue.Queue[tuple[str, str | None]] = queue.Queue()
160+stdout_parts: list[str] = []
161+stderr_parts: list[str] = []
162+163+def read_stream(name: str, stream: Any) -> None:
164+try:
165+for line in iter(stream.readline, ""):
166+events.put((name, line))
167+finally:
168+events.put((name, None))
169+170+def write_stdin() -> None:
171+if proc.stdin is None or input_text is None:
172+return
173+try:
174+proc.stdin.write(input_text)
175+proc.stdin.close()
176+except BrokenPipeError:
177+return
178+179+threads = [
180+threading.Thread(target=read_stream, args=("stdout", proc.stdout), daemon=True),
181+threading.Thread(target=read_stream, args=("stderr", proc.stderr), daemon=True),
182+ ]
183+for thread in threads:
184+thread.start()
185+stdin_thread = threading.Thread(target=write_stdin, daemon=True)
186+stdin_thread.start()
187+188+open_streams = 2
189+while open_streams:
190+try:
191+name, line = events.get(timeout=heartbeat_seconds)
192+except queue.Empty:
193+elapsed = int(time.monotonic() - started)
194+print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True)
195+continue
196+if line is None:
197+open_streams -= 1
198+continue
199+if name == "stdout":
200+stdout_parts.append(line)
201+else:
202+stderr_parts.append(line)
203+display = stream_display(name, line) if stream_display else line
204+if display:
205+target = sys.stdout if name == "stdout" else sys.stderr
206+target.write(display)
207+target.flush()
208+209+for thread in threads:
210+thread.join()
211+stdin_thread.join(timeout=1)
212+returncode = proc.wait()
213+return subprocess.CompletedProcess(args, returncode, "".join(stdout_parts), "".join(stderr_parts))
214+215+127216def git(repo: Path, *args: str, check: bool = True) -> str:
128217return run(["git", *args], repo, check=check).stdout
129218@@ -336,9 +425,11 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
336425cmd.extend(["--model", args.model])
337426if args.thinking:
338427cmd.extend(["-c", f'model_reasoning_effort="{args.thinking}"'])
428+cmd.append("exec")
429+if args.stream_engine_output:
430+cmd.append("--json")
339431cmd.extend(
340432 [
341-"exec",
342433"--ephemeral",
343434"-C",
344435str(repo),
@@ -351,7 +442,14 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
351442"-",
352443 ]
353444 )
354-result = run_with_heartbeat(cmd, repo, input_text=prompt, label="codex")
445+result = run_with_heartbeat(
446+cmd,
447+repo,
448+input_text=prompt,
449+label="codex",
450+stream_output=args.stream_engine_output,
451+stream_display=CodexStreamDisplay() if args.stream_engine_output else None,
452+ )
355453try:
356454output = output_path.read_text()
357455finally:
@@ -368,19 +466,21 @@ def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str:
368466"--print",
369467"--no-session-persistence",
370468"--output-format",
371-"json",
469+"stream-json" if args.stream_engine_output else "json",
372470"--json-schema",
373471json.dumps(SCHEMA),
374472 ]
375473if args.tools:
376474cmd.extend(["--allowedTools", claude_allowed_tools(args)])
377475else:
378476cmd.extend(["--tools", ""])
477+if args.stream_engine_output:
478+cmd.append("--verbose")
379479if args.model:
380480cmd.extend(["--model", args.model])
381481if args.thinking:
382482cmd.extend(["--effort", args.thinking])
383-result = run_with_heartbeat(cmd, repo, input_text=prompt, label="claude")
483+result = run_with_heartbeat(cmd, repo, input_text=prompt, label="claude", stream_output=args.stream_engine_output)
384484if result.returncode != 0:
385485raise SystemExit(f"claude engine failed ({result.returncode})\n{result.stderr or result.stdout}")
386486return result.stdout
@@ -405,7 +505,7 @@ def run_droid(args: argparse.Namespace, repo: Path, prompt: str) -> str:
405505cmd.extend(["--model", args.model])
406506if not args.tools:
407507cmd.extend(["--disabled-tools", "*"])
408-result = run_with_heartbeat(cmd, repo, label="droid")
508+result = run_with_heartbeat(cmd, repo, label="droid", stream_output=args.stream_engine_output)
409509prompt_path.unlink(missing_ok=True)
410510if result.returncode != 0:
411511raise SystemExit(f"droid engine failed ({result.returncode})\n{result.stderr or result.stdout}")
@@ -430,7 +530,7 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
430530"--output-format",
431531"json",
432532"--stream",
433-"off",
533+"on" if args.stream_engine_output else "off",
434534"--no-ask-user",
435535"--disable-builtin-mcps",
436536 ]
@@ -447,12 +547,68 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
447547 )
448548if args.web_search:
449549cmd.append("--allow-all-urls")
450-result = run_with_heartbeat(cmd, Path(tempdir), label="copilot")
550+result = run_with_heartbeat(cmd, Path(tempdir), label="copilot", stream_output=args.stream_engine_output)
451551if result.returncode != 0:
452552raise SystemExit(f"copilot engine failed ({result.returncode})\n{result.stderr or result.stdout}")
453553return result.stdout
454554455555556+class CodexStreamDisplay:
557+def __init__(self, *, activity_seconds: int = 20) -> None:
558+self.activity_seconds = activity_seconds
559+self.hidden_events = 0
560+self.last_visible = time.monotonic()
561+562+def __call__(self, name: str, line: str) -> str | None:
563+if name != "stdout":
564+return line
565+try:
566+event = json.loads(line)
567+except json.JSONDecodeError:
568+return self.visible(line)
569+event_type = event.get("type")
570+if event_type == "thread.started":
571+return self.visible(f"codex thread: {event.get('thread_id', '<unknown>')}\n")
572+if event_type == "turn.started":
573+return self.visible("codex turn started\n")
574+if event_type == "turn.completed":
575+usage = event.get("usage")
576+message = format_codex_usage(usage) + "\n" if isinstance(usage, dict) else "codex turn completed\n"
577+return self.visible(self.flush_hidden() + message)
578+item = event.get("item")
579+if isinstance(item, dict) and item.get("type") == "agent_message" and isinstance(item.get("text"), str):
580+return self.visible(self.flush_hidden() + item["text"].rstrip() + "\n")
581+return self.hidden_activity()
582+583+def hidden_activity(self) -> str | None:
584+self.hidden_events += 1
585+if time.monotonic() - self.last_visible < self.activity_seconds:
586+return None
587+return self.visible(self.flush_hidden())
588+589+def flush_hidden(self) -> str:
590+if not self.hidden_events:
591+return ""
592+count = self.hidden_events
593+self.hidden_events = 0
594+return f"codex activity: {count} hidden tool/status events\n"
595+596+def visible(self, text: str) -> str:
597+self.last_visible = time.monotonic()
598+return text
599+600+601+def format_codex_usage(usage: dict[str, Any]) -> str:
602+fields = [
603+"input_tokens",
604+"cached_input_tokens",
605+"output_tokens",
606+"reasoning_output_tokens",
607+ ]
608+parts = [f"{field}={usage[field]}" for field in fields if isinstance(usage.get(field), int)]
609+return "codex usage: " + " ".join(parts) if parts else "codex usage: unavailable"
610+611+456612def claude_allowed_tools(args: argparse.Namespace) -> str:
457613tools = [tool.strip() for tool in args.claude_allowed_tools.split(",") if tool.strip()]
458614if not args.web_search:
@@ -673,6 +829,12 @@ def parse_args() -> argparse.Namespace:
673829parser.add_argument("--dataset", action="append", help="Extra evidence file to include in the review bundle.")
674830parser.add_argument("--output", help="Write human output to a file as well as stdout.")
675831parser.add_argument("--json-output", help="Write validated structured review JSON.")
832+parser.add_argument(
833+"--stream-engine-output",
834+action="store_true",
835+default=os.environ.get("AUTOREVIEW_STREAM_ENGINE_OUTPUT") == "1",
836+help="Stream review engine output while preserving buffered output for validation. Codex output is filtered to hide tool/file chatter.",
837+ )
676838parser.add_argument("--parallel-tests", help="Run a test command concurrently with review; failure fails the helper.")
677839parser.add_argument("--require-finding", action="append", default=[], help="Require finding text to contain this substring.")
678840parser.add_argument("--expect-findings", action="store_true", help="Treat findings as success; for harness acceptance tests.")
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。