#!/usr/bin/env python3 """Fake `claude` CLI binary for tests/test_claude_runner.py. Runs as a REAL subprocess (spawned by `subprocess.Popen`, exactly like the real CLI) so steering genuinely exercises stdin/stdout pipe timing that a mock `Popen` can't reproduce. Speaks the subset of stream-json that `src/claude_runner.py` depends on: - one `system`/`init` event with a `session_id`, on the first turn only - one `assistant` event per response, with a text block - one `result` event per turn Controlled entirely via environment variables (this process has no access to the test's Python objects): FAKE_CLAUDE_SCENARIO normal | steer | timeout | rate_limit | big_stderr | crash_immediately (default: normal) FAKE_CLAUDE_SESSION_ID session_id to report (default: fake-session-1) The `system`/`init` event also carries the process's own `argv` — tests use this to assert `--resume ` was actually passed on respawn, without needing a separate side channel. """ import json import os import sys import time def _emit(obj: dict) -> None: print(json.dumps(obj), flush=True) def main() -> None: scenario = os.environ.get("FAKE_CLAUDE_SCENARIO", "normal") session_id = os.environ.get("FAKE_CLAUDE_SESSION_ID", "fake-session-1") if scenario == "crash_immediately": sys.exit(1) if scenario == "big_stderr": # T3: without a stderr-draining thread on the caller's side, this # fills the OS pipe buffer (~64KB) and blocks forever, before this # process ever gets to read a turn off stdin. for _ in range(4000): print("x" * 40, file=sys.stderr, flush=True) first_turn = True for raw_line in sys.stdin: raw_line = raw_line.strip() if not raw_line: continue try: msg = json.loads(raw_line) except json.JSONDecodeError: continue text = msg.get("message", {}).get("content", "") if first_turn: _emit({ "type": "system", "subtype": "init", "session_id": session_id, "argv": sys.argv, }) first_turn = False if scenario == "timeout": time.sleep(600) # never responds — caller's own watchdog must kill us return if scenario == "rate_limit": _emit({ "type": "result", "subtype": "error", "session_id": session_id, "result": "You've hit your session limit · resets 10:50am (UTC)", "is_error": True, }) continue if scenario == "generic_error": # is_error=True but NOT a rate limit — C4: must be returned, # never raised, so a future PlanningSession-style caller can # still retry on `subtype` instead of catching an exception. _emit({ "type": "result", "subtype": "error_max_turns", "session_id": session_id, "result": "hit max turns for this task", "is_error": True, }) continue if scenario == "steer": # Simulate a turn in progress that then reads a SECOND stdin # line (the steer) before finishing — exactly what a real # steering exchange looks like (spike: one `result`, num_turns=2). _emit({"type": "assistant", "message": {"content": [ {"type": "text", "text": f"got:{text}"}, ]}}) second_raw = sys.stdin.readline().strip() if second_raw: try: second_msg = json.loads(second_raw) second_text = second_msg.get("message", {}).get("content", "") except json.JSONDecodeError: second_text = "" _emit({"type": "assistant", "message": {"content": [ {"type": "text", "text": f"steered:{second_text}"}, ]}}) _emit({ "type": "result", "subtype": "success", "session_id": session_id, "result": "done", "is_error": False, "num_turns": 2, "usage": {"input_tokens": 1, "output_tokens": 1}, }) continue # normal _emit({"type": "assistant", "message": {"content": [ {"type": "text", "text": f"echo:{text}"}, ]}}) _emit({ "type": "result", "subtype": "success", "session_id": session_id, "result": f"echo:{text}", "is_error": False, "num_turns": 1, "usage": {"input_tokens": 1, "output_tokens": 1}, }) if __name__ == "__main__": main()