From bf6116dc8b7f3708685c5a6e27061859e73eb4c9 Mon Sep 17 00:00:00 2001 From: h Date: Tue, 19 May 2026 11:20:14 +0200 Subject: [PATCH] feat: vibed out some slop over here also --- .gitignore | 16 + .pre-commit-config.yaml | 18 + README.md | 106 ++++ examples/basic_usage.py | 46 ++ examples/mcp_tool.py | 67 +++ examples/multi_turn.py | 69 +++ pyproject.toml | 36 ++ pyrightconfig.json | 10 + ruff.toml | 40 ++ scripts/echo_mcp_server.py | 178 ++++++ scripts/extract_models.py | 93 +++ src/claude_code_api/__init__.py | 79 +++ src/claude_code_api/backend.py | 404 +++++++++++++ src/claude_code_api/errors.py | 138 +++++ src/claude_code_api/events.py | 129 +++++ src/claude_code_api/injection.py | 253 ++++++++ src/claude_code_api/models.py | 129 +++++ src/claude_code_api/normalizer.py | 190 ++++++ src/claude_code_api/paths.py | 113 ++++ src/claude_code_api/pty.py | 389 +++++++++++++ src/claude_code_api/py.typed | 0 src/claude_code_api/turn.py | 320 ++++++++++ src/claude_code_api/watcher.py | 179 ++++++ tests/__init__.py | 0 tests/test_backend.py | 852 +++++++++++++++++++++++++++ tests/test_errors.py | 125 ++++ tests/test_injection.py | 193 ++++++ tests/test_normalizer.py | 421 ++++++++++++++ tests/test_paths.py | 101 ++++ tests/test_pty.py | 261 +++++++++ tests/test_turn.py | 934 ++++++++++++++++++++++++++++++ tests/test_watcher.py | 364 ++++++++++++ ty.toml | 5 + uv.lock | 273 +++++++++ 34 files changed, 6531 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 README.md create mode 100644 examples/basic_usage.py create mode 100644 examples/mcp_tool.py create mode 100644 examples/multi_turn.py create mode 100644 pyproject.toml create mode 100644 pyrightconfig.json create mode 100644 ruff.toml create mode 100644 scripts/echo_mcp_server.py create mode 100644 scripts/extract_models.py create mode 100644 src/claude_code_api/__init__.py create mode 100644 src/claude_code_api/backend.py create mode 100644 src/claude_code_api/errors.py create mode 100644 src/claude_code_api/events.py create mode 100644 src/claude_code_api/injection.py create mode 100644 src/claude_code_api/models.py create mode 100644 src/claude_code_api/normalizer.py create mode 100644 src/claude_code_api/paths.py create mode 100644 src/claude_code_api/pty.py create mode 100644 src/claude_code_api/py.typed create mode 100644 src/claude_code_api/turn.py create mode 100644 src/claude_code_api/watcher.py create mode 100644 tests/__init__.py create mode 100644 tests/test_backend.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_injection.py create mode 100644 tests/test_normalizer.py create mode 100644 tests/test_paths.py create mode 100644 tests/test_pty.py create mode 100644 tests/test_turn.py create mode 100644 tests/test_watcher.py create mode 100644 ty.toml create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30cc475 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +.venv + +.idea +t + +.coverage +.pytest_cache +.ruff_cache +.mypy_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6e51acb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.13 + hooks: + - id: ruff-check + types_or: [python, pyi] + args: [--fix] + - id: ruff-format + types_or: [python, pyi] + + - repo: local + hooks: + - id: ty + name: ty check + entry: uvx ty check + language: python + types_or: [python, pyi] + pass_filenames: false diff --git a/README.md b/README.md new file mode 100644 index 0000000..70b15c3 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# claude-code-api + +Python wrapper around the `claude` CLI for subscription-mode (no API key) +backends. Drives one long-running interactive `claude` per conversation via +a PTY and reads events from the JSONL session file; the public surface is +Anthropic-Messages-API shaped so a gateway in front of it is a one-liner +serializer away. + +Not affiliated with Anthropic. You need a working subscription, the +`claude` CLI on PATH, and to have run `claude /login` once. + +## Install + +As a library inside another project: + +```bash +uv add "claude-code-api @ git+https://git.kotikot.com/beaver/claude-code-api" +``` + +The runtime needs only `ptyprocess`. + +## Use + +```python +import asyncio +from claude_code_api import BackendOptions, ClaudeCodeBackend + +async def main() -> None: + opts = BackendOptions(cwd="/path/to/project", dangerously_skip_permissions=True) + async with ClaudeCodeBackend(opts) as backend: + async for event in backend.complete( + [{"role": "user", "content": "say hi"}] + ): + print(event) + +asyncio.run(main()) +``` + +Multi-turn works by construction — append the assistant reply + a fresh +user message to the same `messages` list and call `complete()` again. The +backend fingerprints `messages[:-1]`, finds the live PTY from the previous +turn, and reuses it (so the server-side prompt cache stays warm): + +```python +history = [{"role": "user", "content": "remember Beaver"}] +async for ev in backend.complete(history): ... + +history += [ + {"role": "assistant", "content": [{"type": "text", "text": "OK"}]}, + {"role": "user", "content": "what was the codeword?"}, +] +async for ev in backend.complete(history): ... +``` + +## Public surface + +Events (Anthropic-style, vendored to keep the dep tree empty): +`AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage`, +`TextBlock`, `ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`. + +Errors: `BackendError` (root), `AuthError`, `ProcessError`, +`CLINotFoundError`, `RateLimitError`, `SessionError`, `MessageParseError`. + +Backend: `ClaudeCodeBackend(opts).complete(messages)` is an async +generator of events. `BackendOptions` exposes model / system prompt / +allowed-tools / `mcp_servers` / permission mode / history injection mode. + +Lower layers (`PtyClaudeProcess`, `JsonlWatcher`, `TurnManager`, +`normalize`) are re-exported for callers that want to assemble their own +session orchestration. + +## How a turn works + +1. The backend looks up a live session by `hash_history(messages[:-1])`. + If one matches, the new user message goes straight into its PTY. +2. If nothing matches and `messages[:-1]` is empty, a fresh `claude` is + spawned with a brand-new `--session-id`. +3. If `messages[:-1]` is non-empty (a continuation we don't have a live + PTY for — e.g. after restart), the backend writes a hand-crafted + JSONL transcript at `~/.claude/projects//.jsonl` and spawns + `claude --resume `. That is the `native_jsonl` injection mode; + the fallback is `concat_message`, which folds the prior history into + one large first prompt. +4. The PTY's stdout is drained continuously by a background thread; we + never read events from there. The JSONL file is tailed at 100ms + cadence and each new record is normalized into a typed `Event`. +5. The turn closes on the first `assistant` record with `stop_reason ∈ + {end_turn, max_tokens, stop_sequence, refusal}`. A `ResultMessage` + is synthesized from its `usage` and yielded last. + +## Examples + +- `examples/basic_usage.py` — one turn, real `claude`. +- `examples/multi_turn.py` — two turns sharing one live PTY. +- `examples/mcp_tool.py` — wire up the bundled echo MCP server and let + the model call it. + +## Tests + +```bash +uv run pytest # unit tests (fast, no real claude) +RUN_CLAUDE_SMOKE=1 uv run pytest tests/test_pty.py tests/test_turn.py tests/test_backend.py +``` + +The smoke-marked tests spawn a real `claude` process and need a logged-in +subscription on the host. diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..d767009 --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,46 @@ +"""Minimal end-to-end example: one turn against a real `claude` CLI. + +Prerequisites: + 1. `claude` is on PATH and the user is logged in (`claude /login` once). + 2. Your subscription is healthy (no rate limit, no auth block). + +Run: + python examples/basic_usage.py +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +from claude_code_api import ( + AssistantMessage, + BackendOptions, + ClaudeCodeBackend, + ResultMessage, + TextBlock, +) + +CWD = Path(os.environ.get("CLAUDE_CODE_CWD", Path.cwd())).resolve() + + +async def main() -> None: + opts = BackendOptions(cwd=str(CWD), dangerously_skip_permissions=True) + async with ClaudeCodeBackend(opts) as backend: + async for event in backend.complete( + [{"role": "user", "content": "Reply with the single word: OK"}] + ): + if isinstance(event, AssistantMessage): + for block in event.content: + if isinstance(block, TextBlock): + print(f"[assistant] {block.text.strip()}") + elif isinstance(event, ResultMessage): + print( + f"[result] stop_reason={event.stop_reason} " + f"duration_ms={event.duration_ms}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/mcp_tool.py b/examples/mcp_tool.py new file mode 100644 index 0000000..0b7076a --- /dev/null +++ b/examples/mcp_tool.py @@ -0,0 +1,67 @@ +"""Tool calling via a stdio MCP server. + +`BackendOptions.mcp_servers` materializes into a temp `--mcp-config` JSON +file under the hood. The model decides when to invoke the tool, claude's +TUI runs the round-trip, and the events stream surfaces a `ToolUseBlock` +plus its `ToolResultBlock`. + +This example wires the bundled echo server at `scripts/echo_mcp_server.py` +— same one the smoke test uses. + +Run: + python examples/mcp_tool.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +from claude_code_api import ( + AssistantMessage, + BackendOptions, + ClaudeCodeBackend, + ResultMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent +ECHO_SCRIPT = REPO_ROOT / "scripts" / "echo_mcp_server.py" +CWD = Path(os.environ.get("CLAUDE_CODE_CWD", Path.cwd())).resolve() + + +async def main() -> None: + opts = BackendOptions( + cwd=str(CWD), + dangerously_skip_permissions=True, + mcp_servers={ + "echo": {"command": sys.executable, "args": [str(ECHO_SCRIPT)]}, + }, + ) + async with ClaudeCodeBackend(opts) as backend: + prompt = ( + "Call the `mcp__echo__echo` tool with text='ping' and then " + "tell me what it returned. Reply with one short sentence." + ) + async for event in backend.complete([{"role": "user", "content": prompt}]): + if isinstance(event, AssistantMessage): + for block in event.content: + if isinstance(block, TextBlock) and block.text.strip(): + print(f"[assistant] {block.text.strip()}") + elif isinstance(block, ToolUseBlock): + print(f"[tool_use] {block.name}({block.input})") + elif isinstance(event, UserMessage) and isinstance(event.content, list): + for block in event.content: + if isinstance(block, ToolResultBlock): + print(f"[tool_res] {block.content!r}") + elif isinstance(event, ResultMessage): + print(f"[result] stop_reason={event.stop_reason}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/multi_turn.py b/examples/multi_turn.py new file mode 100644 index 0000000..e1bda65 --- /dev/null +++ b/examples/multi_turn.py @@ -0,0 +1,69 @@ +"""Multi-turn: same backend, two turns, context persists across the pair. + +The backend fingerprints `messages[:-1]` against its pool of live PTYs. +After turn 1 finishes, we re-send the conversation with the assistant's +reply appended and one new user message — fingerprint matches, the same +process is reused, and the server-side prompt cache keeps the context +without us paying a fresh-spawn tax. + +Run: + python examples/multi_turn.py +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +from typing import Any + +from claude_code_api import ( + AssistantMessage, + BackendOptions, + ClaudeCodeBackend, + ResultMessage, + TextBlock, +) + +CWD = Path(os.environ.get("CLAUDE_CODE_CWD", Path.cwd())).resolve() + + +def assistant_text(events: list[Any]) -> str: + for ev in reversed(events): + if isinstance(ev, AssistantMessage): + return "".join(b.text for b in ev.content if isinstance(b, TextBlock)).strip() + return "" + + +async def run_turn( + backend: ClaudeCodeBackend, history: list[dict[str, Any]] +) -> str: + events: list[Any] = [] + async for event in backend.complete(history): + events.append(event) + if isinstance(event, ResultMessage): + print(f" → stop_reason={event.stop_reason} duration_ms={event.duration_ms}") + return assistant_text(events) + + +async def main() -> None: + opts = BackendOptions(cwd=str(CWD), dangerously_skip_permissions=True) + async with ClaudeCodeBackend(opts) as backend: + history: list[dict[str, Any]] = [ + {"role": "user", "content": "Remember the codeword: Beaver. Reply OK."} + ] + print("[turn 1]") + reply1 = await run_turn(backend, history) + print(f" [assistant] {reply1}") + + history += [ + {"role": "assistant", "content": [{"type": "text", "text": reply1}]}, + {"role": "user", "content": "What was the codeword? Reply with one word."}, + ] + print("[turn 2]") + reply2 = await run_turn(backend, history) + print(f" [assistant] {reply2}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b696783 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "claude-code-api" +version = "0.1.0" +description = "PTY-based wrapper around the `claude` CLI for subscription-mode backends." +requires-python = ">=3.11" +readme = "README.md" +authors = [ + { name = "h", email = "h@kotikot.com" } +] +dependencies = [ + "ptyprocess>=0.7", +] + +[build-system] +requires = ["uv_build>=0.11,<0.12"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = ["claude_code_api"] + +[dependency-groups] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", + "pre-commit>=4.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +markers = [ + "live: hits a real `claude` CLI — needs RUN_CLAUDE_SMOKE=1 and a logged-in subscription", +] +filterwarnings = [ + "ignore:.*forkpty\\(\\) may lead to deadlocks.*:DeprecationWarning", +] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..6fe510b --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,10 @@ +{ + "venvPath": ".", + "venv": ".venv", + "pythonVersion": "3.11", + "include": ["claude_code_api", "tests"], + "exclude": [".venv", "**/__pycache__", ".pytest_cache"], + "extraPaths": ["."], + "reportMissingImports": "error", + "reportMissingTypeStubs": "none" +} diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..74b9948 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,40 @@ +target-version = "py311" + +[lint] +select = ["ALL"] +ignore = [ + "D203", + "D212", + "COM812", + "T201", + "D1", + "PLC0415", + "ANN401", + "PLR0913", + "PLR2004", + "C901", + "PLR0911", + "PLR0912", + "PLR0915", + "BLE001", + "ASYNC109", + "ASYNC110", + "TRY004", +] +unfixable = ["F401"] + +[lint.per-file-ignores] +"tests/*" = ["ALL"] +"examples/*" = ["ALL"] +"scripts/*" = ["ALL"] +"*.lock" = ["ALL"] + +[lint.pydocstyle] +convention = "google" + +[lint.isort] +split-on-trailing-comma = false + +[format] +docstring-code-format = true +skip-magic-trailing-comma = true diff --git a/scripts/echo_mcp_server.py b/scripts/echo_mcp_server.py new file mode 100644 index 0000000..00caddc --- /dev/null +++ b/scripts/echo_mcp_server.py @@ -0,0 +1,178 @@ +"""Minimal stdio MCP server with one tool: `echo`. + +Used by the tool-calling smoke test to exercise the `--mcp-config` path +end-to-end with a real `claude` interactive process. We hand-roll the +JSON-RPC framing rather than pulling in the `mcp` Python SDK so the test +has the same zero-dep posture as the rest of the package. + +Protocol surface (Model Context Protocol, stdio transport): + - `initialize` -> capabilities + serverInfo + protocolVersion + - `notifications/initialized` -> no response + - `tools/list` -> list of {name, description, inputSchema} + - `tools/call` -> content blocks for the named tool + - `ping` -> empty result + +The `echo` tool returns its `text` argument verbatim as a single +`{"type":"text","text":...}` content block. That is enough for claude to +surface a `tool_use` -> `tool_result` pair in the JSONL session file. + +Frame format on the wire is the stdio MCP convention: one JSON object per +line on stdin/stdout. We deliberately do NOT speak the alternative +`Content-Length`-framed flavor — claude's `--mcp-config` stdio transport +uses line framing for spawn-and-pipe servers. + +Run standalone (for protocol sanity-checking) with: + printf '%s\\n%s\\n%s\\n' \\ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"1"}}}' \\ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \\ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}' \\ + | python scripts/echo_mcp_server.py +""" + +from __future__ import annotations + +import json +import os +import sys +import traceback +from pathlib import Path +from typing import Any + +_LOG_PATH = os.environ.get("ECHO_MCP_LOG") + + +def _log(message: str) -> None: + if not _LOG_PATH: + return + try: + with Path(_LOG_PATH).open("a", encoding="utf-8") as f: + f.write(message.rstrip("\n") + "\n") + except OSError: + pass + + +DEFAULT_PROTOCOL_VERSION = "2025-06-18" + +SERVER_NAME = "echo-server" +SERVER_VERSION = "0.0.1" + +ECHO_TOOL = { + "name": "echo", + "description": "Return the supplied `text` argument verbatim.", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to echo back."}, + }, + "required": ["text"], + "additionalProperties": False, + }, +} + + +def _write(message: dict[str, Any]) -> None: + """Emit one JSON-RPC message as a single newline-terminated stdout line.""" + sys.stdout.write(json.dumps(message, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def _ok(req_id: Any, result: dict[str, Any]) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": req_id, "result": result} + + +def _err(req_id: Any, code: int, message: str) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} + + +def _handle_initialize(params: dict[str, Any]) -> dict[str, Any]: + requested = params.get("protocolVersion") if isinstance(params, dict) else None + version = requested if isinstance(requested, str) else DEFAULT_PROTOCOL_VERSION + return { + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}, + } + + +def _handle_tools_list() -> dict[str, Any]: + return {"tools": [ECHO_TOOL]} + + +def _handle_tools_call(params: dict[str, Any]) -> dict[str, Any]: + name = params.get("name") if isinstance(params, dict) else None + arguments = params.get("arguments") if isinstance(params, dict) else None + if name != "echo": + return { + "content": [{"type": "text", "text": f"unknown tool: {name!r}"}], + "isError": True, + } + text = "" + if isinstance(arguments, dict): + raw = arguments.get("text", "") + text = raw if isinstance(raw, str) else json.dumps(raw) + return {"content": [{"type": "text", "text": text}], "isError": False} + + +def _dispatch(message: dict[str, Any]) -> dict[str, Any] | None: + method = message.get("method") + req_id = message.get("id") + params = message.get("params") or {} + + is_notification = "id" not in message + + if method == "initialize": + return _ok(req_id, _handle_initialize(params)) + if method == "notifications/initialized": + return None + if method == "tools/list": + return _ok(req_id, _handle_tools_list()) + if method == "tools/call": + return _ok(req_id, _handle_tools_call(params)) + if method == "ping": + return _ok(req_id, {}) + if method == "prompts/list": + return _ok(req_id, {"prompts": []}) + if method == "resources/list": + return _ok(req_id, {"resources": []}) + if method == "resources/templates/list": + return _ok(req_id, {"resourceTemplates": []}) + + if is_notification: + return None + return _err(req_id, -32601, f"method not found: {method!r}") + + +def main() -> int: + _log(f"START pid={os.getpid()} python={sys.executable} cwd={Path.cwd()}") + try: + while True: + raw_line = sys.stdin.readline() + if not raw_line: + _log("EOF on stdin") + break + line = raw_line.strip() + if not line: + continue + _log(f"RX {line[:2000]}") + try: + message = json.loads(line) + except json.JSONDecodeError as exc: + _log(f"parse error: {exc}") + _write(_err(None, -32700, f"parse error: {exc}")) + continue + if not isinstance(message, dict): + _write(_err(None, -32600, "invalid request: not a JSON object")) + continue + response = _dispatch(message) + if response is not None: + _log(f"TX {json.dumps(response)[:2000]}") + _write(response) + except Exception as exc: + _log("CRASH: " + "".join(traceback.format_exception(exc))) + raise + _log("EXIT 0") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/extract_models.py b/scripts/extract_models.py new file mode 100644 index 0000000..cf754d1 --- /dev/null +++ b/scripts/extract_models.py @@ -0,0 +1,93 @@ +"""Re-derive `claude_code_api.models` constants from a `claude` binary. + +The `claude` CLI is a Mach-O (or ELF) executable that bundles its JS +runtime as plain strings. Model ids and the `/model` alias list show up +verbatim — no decryption needed. This script `strings`-greps a given +binary and prints a refreshed `src/claude_code_api/models.py`. + +Usage: + uv run python scripts/extract_models.py # auto-locate + uv run python scripts/extract_models.py /path/to/claude # explicit + +Auto-location order (macOS): + ~/.local/share/claude/versions/ + $(which claude) (follows symlinks) +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path + +_MODEL_ID_RE = re.compile( + rb'"(claude-(?:opus|sonnet|haiku|[0-9])[a-z0-9-]*-(?:opus|sonnet|haiku|[0-9])[a-z0-9-]*)"' +) +_ALIAS_LIST_RE = re.compile(rb'\["sonnet","opus","haiku".*?"opusplan"\]') + + +def find_default_binary() -> Path | None: + home = Path.home() + versions_dir = home / ".local" / "share" / "claude" / "versions" + if versions_dir.is_dir(): + versions = sorted( + (p for p in versions_dir.iterdir() if p.is_file()), + key=lambda p: p.name, + ) + if versions: + return versions[-1] + on_path = shutil.which("claude") + if on_path is not None: + return Path(on_path).resolve() + return None + + +def extract_strings(binary: Path) -> bytes: + result = subprocess.run( + ["strings", str(binary)], + check=True, + capture_output=True, + ) + return result.stdout + + +def extract_model_ids(blob: bytes) -> set[str]: + return {m.group(1).decode("ascii") for m in _MODEL_ID_RE.finditer(blob)} + + +def extract_aliases(blob: bytes) -> list[str]: + match = _ALIAS_LIST_RE.search(blob) + if match is None: + return [] + raw = match.group(0).decode("ascii") + return re.findall(r'"([^"]+)"', raw) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("binary", nargs="?", help="Path to claude executable") + args = parser.parse_args() + + binary = Path(args.binary) if args.binary else find_default_binary() + if binary is None or not binary.is_file(): + print(f"could not locate claude binary: {binary}", file=sys.stderr) + return 2 + + blob = extract_strings(binary) + ids = sorted(extract_model_ids(blob)) + aliases = extract_aliases(blob) + + print(f"# binary: {binary}", file=sys.stderr) + print(f"# {len(ids)} model ids, {len(aliases)} aliases", file=sys.stderr) + print("aliases:", aliases) + print("models:") + for model_id in ids: + print(f" {model_id}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/claude_code_api/__init__.py b/src/claude_code_api/__init__.py new file mode 100644 index 0000000..456b7d0 --- /dev/null +++ b/src/claude_code_api/__init__.py @@ -0,0 +1,79 @@ +"""PTY-based wrapper around the `claude` CLI for subscription-mode backends. + +`ClaudeCodeBackend` + `BackendOptions` is the surface a gateway +consumes. `TurnManager` and the typed events / errors are re-exported for +callers that want to assemble the lower layers directly (e.g. tests, custom +session orchestration). +""" + +from claude_code_api.backend import ( + BackendOptions, + ClaudeCodeBackend, + HistoryInjectionMode, +) +from claude_code_api.errors import ( + AuthError, + BackendError, + CLINotFoundError, + MessageParseError, + ProcessError, + RateLimitError, + SessionError, + classify_pty_failure, +) +from claude_code_api.events import ( + AssistantMessage, + ContentBlock, + Event, + ResultMessage, + SystemMessage, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) +from claude_code_api.models import ( + ALIASES, + DISPLAY_NAMES, + MODELS_ALL, + MODELS_CURRENT, + MODELS_LEGACY, + is_valid_model, +) +from claude_code_api.normalizer import normalize +from claude_code_api.turn import TurnManager + +__version__ = "0.1.0" + +__all__ = [ + "ALIASES", + "DISPLAY_NAMES", + "MODELS_ALL", + "MODELS_CURRENT", + "MODELS_LEGACY", + "AssistantMessage", + "AuthError", + "BackendError", + "BackendOptions", + "CLINotFoundError", + "ClaudeCodeBackend", + "ContentBlock", + "Event", + "HistoryInjectionMode", + "MessageParseError", + "ProcessError", + "RateLimitError", + "ResultMessage", + "SessionError", + "SystemMessage", + "TextBlock", + "ThinkingBlock", + "ToolResultBlock", + "ToolUseBlock", + "TurnManager", + "UserMessage", + "classify_pty_failure", + "is_valid_model", + "normalize", +] diff --git a/src/claude_code_api/backend.py b/src/claude_code_api/backend.py new file mode 100644 index 0000000..62002c7 --- /dev/null +++ b/src/claude_code_api/backend.py @@ -0,0 +1,404 @@ +"""The gateway-facing public API. + +`ClaudeCodeBackend` is the only class the gateway needs to know +about. It owns: + +- a pool of live `claude` sessions, keyed by a fingerprint of conversation + history, so a continuing turn reuses an existing PTY (and the + server-side prompt cache) instead of paying a fresh-spawn tax; +- the choice between `native_jsonl` (default) and `concat_message` + (fallback) for seeding a session with prior history that the gateway + sends in but no live session matches; +- the conversion from `BackendOptions` (high-level, takes a dict of MCP + servers) into `PtyProcessOptions` (low-level, takes argv-ready flags), + including materializing an `--mcp-config` file when `mcp_servers` is set. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import tempfile +import uuid +from collections.abc import AsyncIterator, Callable, Iterable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, Self + +from claude_code_api.errors import MessageParseError +from claude_code_api.events import ( + AssistantMessage, + ContentBlock, + Event, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, +) +from claude_code_api.injection import ( + build_concat_prompt, + build_seed_jsonl, + hash_history, +) +from claude_code_api.paths import resolve_jsonl_path +from claude_code_api.pty import PtyClaudeProcess, PtyProcessOptions +from claude_code_api.turn import TurnManager +from claude_code_api.watcher import JsonlWatcher + +HistoryInjectionMode = Literal["native_jsonl", "concat_message"] + +ParseErrorCallback = Callable[[MessageParseError, dict[str, Any]], None] + +_TERMINAL_STOP_REASONS: frozenset[str] = frozenset( + {"end_turn", "max_tokens", "stop_sequence", "refusal"} +) + + +@dataclass(frozen=True) +class BackendOptions: + """High-level configuration for `ClaudeCodeBackend`. + + Mirrors `PtyProcessOptions` shape but speaks the gateway's vocabulary: + `mcp_servers` is a `{name: config}` mapping (materialized into a temp + `--mcp-config` file under the hood) rather than a tuple of file paths. + """ + + cwd: str | os.PathLike[str] + model: str | None = None + system_prompt: str | None = None + append_system_prompt: str | None = None + allowed_tools: tuple[str, ...] = () + disallowed_tools: tuple[str, ...] = () + mcp_servers: Mapping[str, Mapping[str, Any]] | None = None + permission_mode: str = "bypassPermissions" + dangerously_skip_permissions: bool = False + effort: str | None = None + add_dir: tuple[str, ...] = () + settings: str | None = None + extra_args: tuple[str, ...] = () + extra_env: Mapping[str, str] = field(default_factory=dict) + preserve_provider_env: bool = False + + history_injection_mode: HistoryInjectionMode = "native_jsonl" + wait_for_turn_duration: bool = False + include_meta_user: bool = False + startup_delay: float = 1.0 + file_wait_timeout: float = 30.0 + turn_duration_timeout: float = 5.0 + + +@dataclass +class _LiveSession: + """One live PTY + watcher + turn manager. Created per conversation.""" + + pty: PtyClaudeProcess + watcher: JsonlWatcher + tm: TurnManager + + @property + def session_id(self) -> str: + return self.pty.session_id + + async def aclose(self) -> None: + await self.tm.aclose() + + +SessionFactory = Callable[ + ["ClaudeCodeBackend", str, bool, Path, int], + "asyncio.Future[_LiveSession] | _LiveSession", +] + + +class ClaudeCodeBackend: + """Persistent multi-session wrapper around the subscription `claude` CLI. + + Lifecycle: + async with ClaudeCodeBackend(opts) as backend: + async for event in backend.complete([{"role": "user", "content": "hi"}]): + ... + + Each call to `complete()` either reuses a live PTY (if the new + `messages[:-1]` matches one we already have running) or spawns a fresh + session, optionally seeding it with prior history. On success, the + session is stashed under a new fingerprint that incorporates this + turn, so the next request can find it. + """ + + def __init__( + self, + options: BackendOptions, + *, + on_parse_error: ParseErrorCallback | None = None, + _session_factory: SessionFactory | None = None, + ) -> None: + self._opts = options + self._on_parse_error = on_parse_error + self._sessions: dict[str, _LiveSession] = {} + self._mcp_config_path: Path | None = None + self._session_factory = _session_factory + self._closed = False + self._lock = asyncio.Lock() + + @property + def options(self) -> BackendOptions: + return self._opts + + @property + def live_session_count(self) -> int: + return len(self._sessions) + + async def complete(self, messages: list[Mapping[str, Any]]) -> AsyncIterator[Event]: + """Run one turn against the matching session (or spawn one). + + `messages` is an Anthropic-Messages-API style list — alternating + user/assistant entries ending with a user entry. The backend uses + `messages[:-1]` to look up a live session by fingerprint; if none + matches it creates one (seeded with that history if non-empty). + Yields typed events as they arrive; the final event is the + synthesized `ResultMessage` from `TurnManager`. + """ + if self._closed: + msg = "ClaudeCodeBackend is closed" + raise RuntimeError(msg) + if not messages: + msg = "messages must not be empty" + raise ValueError(msg) + last = messages[-1] + if last.get("role") != "user": + msg = "last message must have role='user'" + raise ValueError(msg) + last_text = _user_text_payload(last.get("content")) + + async with self._lock: + prior = list(messages[:-1]) + fp_prior = hash_history(prior) + + session: _LiveSession + if prior and fp_prior in self._sessions: + session = self._sessions.pop(fp_prior) + send_text = last_text + else: + session = await self._create_session(prior) + if prior and self._opts.history_injection_mode == "concat_message": + send_text = build_concat_prompt(prior, last_text) + else: + send_text = last_text + + events: list[Event] = [] + try: + async for event in session.tm.send_user_message(send_text): + events.append(event) + yield event + except BaseException: + with contextlib.suppress(Exception): + await session.aclose() + raise + + synthetic_asst = _synthesize_assistant_dict(events) + new_history = [*list(messages), synthetic_asst] + self._sessions[hash_history(new_history)] = session + + async def aclose(self) -> None: + """Shut down all live sessions; remove the temp mcp-config file.""" + self._closed = True + sessions = list(self._sessions.values()) + self._sessions.clear() + for s in sessions: + with contextlib.suppress(Exception): + await s.aclose() + if self._mcp_config_path is not None: + with contextlib.suppress(OSError): + self._mcp_config_path.unlink() + self._mcp_config_path = None + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None: + await self.aclose() + + async def _create_session(self, history: list[Mapping[str, Any]]) -> _LiveSession: + """Spawn a fresh PTY + watcher + manager, optionally seeded. + + `native_jsonl` (default): write a hand-crafted JSONL transcript at + `~/.claude/projects//.jsonl`, then start claude + with `--resume `. The watcher starts at the seed + file's end so it sees only fresh records. + + `concat_message` (fallback): spawn fresh; the history is injected + into the first user prompt instead (handled by `complete()`). + """ + session_id = str(uuid.uuid4()) + cwd = os.fspath(self._opts.cwd) + + if history and self._opts.history_injection_mode == "native_jsonl": + jsonl_path = resolve_jsonl_path(cwd, session_id) + jsonl_path.parent.mkdir(parents=True, exist_ok=True) + seed = build_seed_jsonl(history, session_id=session_id, cwd=cwd) + jsonl_path.write_text(seed, encoding="utf-8") + start_offset = jsonl_path.stat().st_size + resume = True + else: + jsonl_path = resolve_jsonl_path(cwd, session_id) + start_offset = 0 + resume = False + + if self._session_factory is not None: + result = self._session_factory( + self, session_id, resume, jsonl_path, start_offset + ) + if asyncio.iscoroutine(result): + return await result + return result # type: ignore[return-value] + + return await self._spawn_real_session( + session_id=session_id, + resume=resume, + jsonl_path=jsonl_path, + start_offset=start_offset, + ) + + async def _spawn_real_session( + self, *, session_id: str, resume: bool, jsonl_path: Path, start_offset: int + ) -> _LiveSession: + pty_opts = self._build_pty_options(session_id=session_id, resume=resume) + pty = PtyClaudeProcess(pty_opts) + watcher = JsonlWatcher(jsonl_path, start_offset=start_offset) + tm = TurnManager( + pty, + watcher, + wait_for_turn_duration=self._opts.wait_for_turn_duration, + include_meta_user=self._opts.include_meta_user, + file_wait_timeout=self._opts.file_wait_timeout, + turn_duration_timeout=self._opts.turn_duration_timeout, + startup_delay=self._opts.startup_delay, + on_parse_error=self._on_parse_error, + ) + await tm.start() + return _LiveSession(pty=pty, watcher=watcher, tm=tm) + + def _build_pty_options(self, *, session_id: str, resume: bool) -> PtyProcessOptions: + mcp_config = self._mcp_config_argument() + kwargs: dict[str, Any] = { + "cwd": self._opts.cwd, + "model": self._opts.model, + "system_prompt": self._opts.system_prompt, + "append_system_prompt": self._opts.append_system_prompt, + "allowed_tools": self._opts.allowed_tools, + "disallowed_tools": self._opts.disallowed_tools, + "mcp_config": mcp_config, + "add_dir": self._opts.add_dir, + "permission_mode": self._opts.permission_mode, + "dangerously_skip_permissions": self._opts.dangerously_skip_permissions, + "effort": self._opts.effort, + "settings": self._opts.settings, + "extra_args": self._opts.extra_args, + "preserve_provider_env": self._opts.preserve_provider_env, + "extra_env": self._opts.extra_env, + } + if resume: + kwargs["resume_session_id"] = session_id + else: + kwargs["session_id"] = session_id + return PtyProcessOptions(**kwargs) + + def _mcp_config_argument(self) -> tuple[str, ...]: + """Materialize `mcp_servers` into a `--mcp-config` file path tuple. + + The temp file lives for the backend's lifetime — cleaned up in + `aclose()`. Written lazily so a backend that never spawns a + session leaves no debris. + """ + servers = self._opts.mcp_servers + if not servers: + return () + if self._mcp_config_path is None: + fd, path = tempfile.mkstemp(prefix="claude-mcp-", suffix=".json") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump({"mcpServers": dict(servers)}, f) + except Exception: + with contextlib.suppress(OSError): + Path(path).unlink() + raise + self._mcp_config_path = Path(path) + return (str(self._mcp_config_path),) + + +def _user_text_payload(content: Any) -> str: + """Extract the text we'll write to the PTY for the last user message. + + A string `content` passes through as-is. A list of blocks is flattened + to its text content; tool_result blocks are not faithfully + reproducible through stdin and are skipped. + """ + if isinstance(content, str): + return content + if isinstance(content, list): + chunks: list[str] = [] + for block in content: + if isinstance(block, Mapping) and block.get("type") == "text": + text = block.get("text") + if isinstance(text, str): + chunks.append(text) + if not chunks: + msg = "last user message content must include at least one text block" + raise ValueError(msg) + return " ".join(chunks) + msg = f"last user message content must be str or list, got {type(content).__name__}" + raise ValueError(msg) + + +def _synthesize_assistant_dict(events: Iterable[Event]) -> dict[str, Any]: + """Render the terminal assistant message in Anthropic Messages format.""" + terminal: AssistantMessage | None = None + for ev in reversed(list(events)): + if ( + isinstance(ev, AssistantMessage) + and ev.stop_reason in _TERMINAL_STOP_REASONS + ): + terminal = ev + break + if terminal is None: + return {"role": "assistant", "content": []} + return { + "role": "assistant", + "content": [_block_to_dict(b) for b in terminal.content], + } + + +def _block_to_dict(block: ContentBlock) -> dict[str, Any]: + if isinstance(block, TextBlock): + return {"type": "text", "text": block.text} + if isinstance(block, ToolUseBlock): + return { + "type": "tool_use", + "id": block.id, + "name": block.name, + "input": block.input, + } + if isinstance(block, ToolResultBlock): + return { + "type": "tool_result", + "tool_use_id": block.tool_use_id, + "content": block.content, + "is_error": block.is_error, + } + if isinstance(block, ThinkingBlock): + return { + "type": "thinking", + "thinking": block.thinking, + "signature": block.signature, + } + msg = f"unknown content block type: {type(block).__name__}" + raise TypeError(msg) + + +__all__ = [ + "BackendOptions", + "ClaudeCodeBackend", + "HistoryInjectionMode", + "ParseErrorCallback", +] diff --git a/src/claude_code_api/errors.py b/src/claude_code_api/errors.py new file mode 100644 index 0000000..3c8af3a --- /dev/null +++ b/src/claude_code_api/errors.py @@ -0,0 +1,138 @@ +"""Backend exception hierarchy. + +Mirrors `claude_agent_sdk._errors` so a gateway that already catches its +shapes keeps working when it wires this backend in. + +- `SessionError` is raised when the JSONL session file never appears within + `TurnManager.file_wait_timeout`. +- `ProcessError` carries `exit_code` and `stderr` (the rolling PTY output + buffer — claude's TUI writes its error chrome to the PTY stream, not to + stderr). +- `classify_pty_failure(captured)` inspects that buffer for known error + markers and returns the most specific subclass to raise (or `None` for + "no signal — caller picks the default"). +""" + +from __future__ import annotations + +import re +from typing import Any + + +class BackendError(Exception): + """Base for every error raised by this package.""" + + +class MessageParseError(BackendError): + """A JSONL record was malformed or missing fields required to type it.""" + + def __init__(self, message: str, data: Any = None) -> None: + self.data = data + super().__init__(message) + + +class ProcessError(BackendError): + """The `claude` subprocess died unexpectedly or refused to start. + + `exit_code` is `None` if we never observed an exit (e.g. the process is + still alive but unresponsive); `stderr` carries the rolling PTY output + buffer at the moment of failure (capped, oldest dropped) — claude prints + its error chrome there rather than to stderr. + """ + + def __init__( + self, message: str, *, exit_code: int | None = None, stderr: str | None = None + ) -> None: + self.exit_code = exit_code + self.stderr = stderr + if exit_code is not None: + message = f"{message} (exit code: {exit_code})" + if stderr: + tail = stderr[-2000:] if len(stderr) > 2000 else stderr + message = f"{message}\nPTY output (tail):\n{tail}" + super().__init__(message) + + +class CLINotFoundError(ProcessError): + """The `claude` binary could not be located on PATH.""" + + def __init__( + self, message: str = "claude CLI not found", *, executable: str | None = None + ) -> None: + if executable: + message = f"{message}: {executable}" + self.executable = executable + super().__init__(message) + + +class AuthError(BackendError): + """OAuth / subscription auth is blocked — user must re-`claude /login`.""" + + def __init__(self, message: str = "claude subscription auth is blocked") -> None: + super().__init__(message) + + +class RateLimitError(BackendError): + """Subscription rate limit hit.""" + + def __init__(self, message: str = "claude subscription rate limit reached") -> None: + super().__init__(message) + + +class SessionError(BackendError): + """JSONL session file failed to materialize within the configured timeout. + + Usually means claude is hung in startup (e.g. workspace-trust prompt + blocking), or its TUI never accepted our stdin write. + """ + + +_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +_NON_ALNUM_RE = re.compile(r"[^a-z0-9]+") + + +def _compact(text: str) -> str: + return _NON_ALNUM_RE.sub("", _ANSI_RE.sub("", text).lower()) + + +def classify_pty_failure(captured: bytes | str) -> type[BackendError] | None: + """Inspect a PTY-output buffer for known TUI error-chrome markers. + + Returns the most specific `BackendError` subclass to raise, or `None` + when no marker is matched (caller falls back to a generic + `ProcessError`/`SessionError`). + """ + text = ( + captured.decode("utf-8", errors="replace") + if isinstance(captured, bytes) + else captured + ) + low = _ANSI_RE.sub("", text).lower() + compact = _compact(text) + + if ( + "failed to authenticate" in low + or "api error: 403" in low + or "pleaserunlogin" in compact + or "pleaserun/login" in compact + ): + return AuthError + if ( + "you've hit your limit" in low + or "you have hit your limit" in low + or "hit your limit" in low + ): + return RateLimitError + return None + + +__all__ = [ + "AuthError", + "BackendError", + "CLINotFoundError", + "MessageParseError", + "ProcessError", + "RateLimitError", + "SessionError", + "classify_pty_failure", +] diff --git a/src/claude_code_api/events.py b/src/claude_code_api/events.py new file mode 100644 index 0000000..dfe7422 --- /dev/null +++ b/src/claude_code_api/events.py @@ -0,0 +1,129 @@ +"""Public event types emitted by the backend. + +The shapes mirror the `claude-agent-sdk` Python types (`AssistantMessage`, +`UserMessage`, `SystemMessage`, `ResultMessage`, plus the content-block +dataclasses) so the gateway that consumes this backend can re-expose +everything over an Anthropic Messages API-compatible HTTP surface with a +one-liner serializer. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class TextBlock: + text: str + + +@dataclass +class ThinkingBlock: + thinking: str + signature: str + + +@dataclass +class ToolUseBlock: + id: str + name: str + input: dict[str, Any] + + +@dataclass +class ToolResultBlock: + tool_use_id: str + content: str | list[dict[str, Any]] | None = None + is_error: bool | None = None + + +ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock + + +@dataclass +class UserMessage: + """User turn record from JSONL. + + `content` is either a verbatim string (the prompt we sent) or a list of + content blocks (the only observed block-list shape is a list of + `ToolResultBlock`s, written by claude after a tool call completes). + """ + + content: str | list[ContentBlock] + uuid: str | None = None + session_id: str | None = None + parent_uuid: str | None = None + + +@dataclass +class AssistantMessage: + """Assistant turn record. + + `stop_reason` can be ``None`` for intermediate streaming chunks claude + writes mid-turn; only terminal values (``end_turn`` / ``tool_use`` / + ``max_tokens`` / ``stop_sequence`` / ``refusal``) close out a turn. + """ + + content: list[ContentBlock] + model: str + stop_reason: str | None = None + usage: dict[str, Any] | None = None + message_id: str | None = None + session_id: str | None = None + uuid: str | None = None + parent_uuid: str | None = None + + +@dataclass +class SystemMessage: + """Out-of-band signal record. + + The only subtype the normalizer surfaces by default is ``turn_duration``, + which marks the end of a turn (after all post-turn hooks have flushed). + All other system subtypes (`stop_hook_summary`, `local_command`) are + filtered out — they are claude's internal bookkeeping, not consumer + signal. `data` carries the full raw record for callers that want it. + """ + + subtype: str + data: dict[str, Any] + session_id: str | None = None + uuid: str | None = None + + +@dataclass +class ResultMessage: + """Synthesized turn-completion summary. + + Not emitted by the normalizer — JSONL has no native ``result`` record. + `TurnManager` fabricates one when a turn closes, aggregating usage from + the last assistant record and timing from the final `turn_duration` + system signal. + """ + + subtype: str + duration_ms: int + num_turns: int + session_id: str + is_error: bool = False + stop_reason: str | None = None + usage: dict[str, Any] | None = None + uuid: str | None = None + + +Event = UserMessage | AssistantMessage | SystemMessage | ResultMessage + + +__all__ = [ + "AssistantMessage", + "ContentBlock", + "Event", + "ResultMessage", + "SystemMessage", + "TextBlock", + "ThinkingBlock", + "ToolResultBlock", + "ToolUseBlock", + "UserMessage", +] diff --git a/src/claude_code_api/injection.py b/src/claude_code_api/injection.py new file mode 100644 index 0000000..f6f0242 --- /dev/null +++ b/src/claude_code_api/injection.py @@ -0,0 +1,253 @@ +"""History fingerprinting + injection formats. + +Three pure functions: + +- `hash_history(messages)` — deterministic fingerprint of an Anthropic-style + message list. Two requests whose `messages[:-1]` hash to the same value + are considered the same conversation up to that turn, regardless of which + client sent them. + +- `build_seed_jsonl(messages, session_id, cwd, ...)` — render a prior + history as a native `claude` JSONL transcript ready to be written under + `~/.claude/projects//.jsonl` and resumed via + `claude --resume `. + +- `build_concat_prompt(history, last_user_text)` — render the same prior + history as a single big text prompt for the fallback `concat_message` + injection mode (no JSONL surgery; everything goes through stdin). + +This module does NOT touch the filesystem. +""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import json +import uuid +from collections.abc import Iterable, Mapping +from typing import Any + +_DEFAULT_CLAUDE_VERSION = "2.1.143" +_DEFAULT_MODEL = "claude-opus-4-7" + + +def hash_history(messages: Iterable[Mapping[str, Any]]) -> str: + """Return a stable sha256 hex digest of a conversation prefix. + + Only the content-bearing fields are considered: `role`, `content` (with + text/tool_use/tool_result blocks normalized to their semantic shape). + Ordering of dict keys inside blocks is normalized via canonical-JSON + (`sort_keys=True`) so two clients that serialize the same content + blocks in different key orders still collide. + """ + canonical = [_canonical_message(m) for m in messages] + payload = json.dumps(canonical, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _canonical_message(message: Mapping[str, Any]) -> dict[str, Any]: + role = message.get("role") + if role not in ("user", "assistant"): + msg = f"message must have role 'user' or 'assistant', got {role!r}" + raise ValueError(msg) + content = message.get("content") + return {"role": role, "content": _canonical_content(content)} + + +def _canonical_content(content: Any) -> Any: + if isinstance(content, str): + return content + if isinstance(content, list): + return [_canonical_block(b) for b in content] + msg = f"content must be str or list, got {type(content).__name__}" + raise ValueError(msg) + + +def _canonical_block(block: Any) -> dict[str, Any]: + if not isinstance(block, Mapping): + msg = f"content block must be a mapping, got {type(block).__name__}" + raise ValueError(msg) + btype = block.get("type") + if btype == "text": + return {"type": "text", "text": block.get("text", "")} + if btype == "tool_use": + return { + "type": "tool_use", + "id": block.get("id", ""), + "name": block.get("name", ""), + "input": block.get("input", {}), + } + if btype == "tool_result": + return { + "type": "tool_result", + "tool_use_id": block.get("tool_use_id", ""), + "content": block.get("content"), + "is_error": block.get("is_error"), + } + return {"type": btype, **{k: v for k, v in block.items() if k != "type"}} + + +def build_seed_jsonl( + messages: Iterable[Mapping[str, Any]], + *, + session_id: str, + cwd: str, + claude_version: str = _DEFAULT_CLAUDE_VERSION, + model: str = _DEFAULT_MODEL, + now_iso: str | None = None, +) -> str: + """Render a message list as a native claude JSONL transcript. + + Output is a newline-terminated string of one JSON object per line. The + schema mirrors what claude itself writes (minus the snapshot records). + + The caller writes the result to + `~/.claude/projects//.jsonl` and spawns + `claude --resume `. Claude appends its own + `file-history-snapshot` / `last-prompt` / `permission-mode` records on + resume — we don't need to. + + Empty history is permitted; the returned string is empty in that case. + """ + if now_iso is None: + now_iso = _now_iso() + + lines: list[str] = [] + parent_uuid: str | None = None + common = { + "isSidechain": False, + "userType": "external", + "entrypoint": "cli", + "cwd": cwd, + "sessionId": session_id, + "version": claude_version, + "gitBranch": "", + } + for m in messages: + role = m.get("role") + if role == "user": + user_uuid = str(uuid.uuid4()) + record = { + "parentUuid": parent_uuid, + "promptId": str(uuid.uuid4()), + "type": "user", + "message": { + "role": "user", + "content": _content_for_seed(m.get("content"), role="user"), + }, + "isMeta": False, + "uuid": user_uuid, + "timestamp": now_iso, + **common, + } + parent_uuid = user_uuid + elif role == "assistant": + assistant_uuid = str(uuid.uuid4()) + record = { + "parentUuid": parent_uuid, + "message": { + "model": model, + "id": f"msg_{uuid.uuid4().hex[:24]}", + "type": "message", + "role": "assistant", + "content": _content_for_seed(m.get("content"), role="assistant"), + "stop_reason": "end_turn", + "stop_sequence": None, + "stop_details": None, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "service_tier": "standard", + }, + }, + "requestId": f"req_{uuid.uuid4().hex[:24]}", + "type": "assistant", + "uuid": assistant_uuid, + "timestamp": now_iso, + **common, + } + parent_uuid = assistant_uuid + else: + msg = f"message role must be 'user' or 'assistant', got {role!r}" + raise ValueError(msg) + lines.append(json.dumps(record)) + if not lines: + return "" + return "\n".join(lines) + "\n" + + +def _content_for_seed(content: Any, *, role: str) -> Any: + """Normalize Anthropic message content to what claude expects in JSONL.""" + if role == "user": + if isinstance(content, str): + return content + if isinstance(content, list): + return [dict(b) for b in content] + msg = f"user content must be str or list, got {type(content).__name__}" + raise ValueError(msg) + if isinstance(content, str): + return [{"type": "text", "text": content}] + if isinstance(content, list): + return [dict(b) for b in content] + msg = f"assistant content must be str or list, got {type(content).__name__}" + raise ValueError(msg) + + +_CONCAT_PREAMBLE = "Previous conversation context:" +_CONCAT_DIVIDER = "Continue from here. New user message:" + + +def build_concat_prompt( + history: Iterable[Mapping[str, Any]], last_user_text: str +) -> str: + """Render prior history + the new user prompt as one stdin payload. + + Fallback for when `native_jsonl` injection can't be used. Costs more + tokens per request and breaks the server-side prompt cache, but always + works because it goes through the same stdin path as a normal first + turn. + """ + parts: list[str] = [] + history_list = list(history) + if history_list: + parts.append(_CONCAT_PREAMBLE) + parts.append("") + for m in history_list: + role = m.get("role") + if role not in ("user", "assistant"): + msg = f"message role must be 'user' or 'assistant', got {role!r}" + raise ValueError(msg) + label = "[User]" if role == "user" else "[Assistant]" + parts.append(f"{label}: {_flatten_text(m.get('content'))}") + parts.append("") + parts.append(f"{_CONCAT_DIVIDER} {last_user_text}") + else: + parts.append(last_user_text) + return "\n".join(parts) + + +def _flatten_text(content: Any) -> str: + """Extract a single string from a content payload for concat mode.""" + if isinstance(content, str): + return content + if isinstance(content, list): + chunks: list[str] = [] + for b in content: + if isinstance(b, Mapping) and b.get("type") == "text": + text = b.get("text") + if isinstance(text, str): + chunks.append(text) + return " ".join(chunks) + return "" + + +def _now_iso() -> str: + """RFC3339 with millisecond precision and a 'Z' suffix, matching claude.""" + now = dt.datetime.now(dt.UTC) + return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z" + + +__all__ = ["build_concat_prompt", "build_seed_jsonl", "hash_history"] diff --git a/src/claude_code_api/models.py b/src/claude_code_api/models.py new file mode 100644 index 0000000..47bf400 --- /dev/null +++ b/src/claude_code_api/models.py @@ -0,0 +1,129 @@ +"""Hardcoded inventory of `claude` CLI models and aliases. + +Sourced by inspecting the Mach-O binary at +`~/.local/share/claude/versions/` on macOS (or the equivalent Linux +location). The data lives in plain string literals — no decryption or +unpacking needed. See `scripts/extract_models.py` to refresh after a +`claude` update. + +The CLI accepts either a *short alias* (e.g. ``sonnet``) or a *full model +id* (e.g. ``claude-sonnet-4-6``) for ``--model``. Aliases get resolved to +whatever Anthropic considers current; pin a full id when you need +stability across `claude` updates. + +Last refreshed against `claude 2.1.143`. +""" + +from __future__ import annotations + +from typing import Final + +CLAUDE_VERSION: Final = "2.1.143" + +ALIASES: Final[tuple[str, ...]] = ( + "default", + "sonnet", + "opus", + "haiku", + "best", + "sonnet[1m]", + "opus[1m]", + "opusplan", +) +"""Short aliases the CLI's `/model` command lists as ``Available:``. + +`opusplan` routes /plan calls through opus and execution through sonnet. +`*[1m]` selects the 1M-context variant of the family (requires entitlement). +`best` resolves to whatever Anthropic currently advertises as flagship. +""" + + +MODELS_CURRENT: Final[tuple[str, ...]] = ( + "claude-opus-4-7", + "claude-opus-4-6", + "claude-opus-4-6-fast", + "claude-sonnet-4-6", + "claude-haiku-4-5", +) +"""Currently-recommended model ids — current minor release of each family.""" + + +MODELS_LEGACY: Final[tuple[str, ...]] = ( + "claude-opus-4-5", + "claude-opus-4-5-20251101", + "claude-opus-4-1", + "claude-opus-4-1-20250805", + "claude-opus-4-0", + "claude-opus-4", + "claude-opus-4-20250514", + "claude-4-opus-20250514", + "claude-sonnet-4-5", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-0", + "claude-sonnet-4", + "claude-sonnet-4-20250514", + "claude-sonnet-3-7", + "claude-haiku-4", + "claude-haiku-3-5", + "claude-3-7-sonnet", + "claude-3-7-sonnet-latest", + "claude-3-7-sonnet-20250219", + "claude-3-5-sonnet", + "claude-3-5-sonnet-20241022", + "claude-3-5-haiku", + "claude-3-5-haiku-latest", + "claude-3-5-haiku-20241022", + "claude-3-opus", + "claude-3-opus-20240229", + "claude-3-sonnet", + "claude-3-sonnet-20240229", + "claude-3-haiku", +) +"""Older model ids the CLI still accepts but Anthropic no longer fronts.""" + + +MODELS_ALL: Final[tuple[str, ...]] = MODELS_CURRENT + MODELS_LEGACY +"""Every model id the `claude 2.1.143` binary references.""" + + +DISPLAY_NAMES: Final[dict[str, str]] = { + "claude-opus-4-7": "Opus 4.7", + "claude-opus-4-6": "Opus 4.6", + "claude-opus-4-6-fast": "Opus 4.6 (fast)", + "claude-opus-4-5": "Opus 4.5", + "claude-opus-4-1": "Opus 4.1", + "claude-opus-4": "Opus 4", + "claude-sonnet-4-6": "Sonnet 4.6", + "claude-sonnet-4-5": "Sonnet 4.5", + "claude-sonnet-4": "Sonnet 4", + "claude-haiku-4-5": "Haiku 4.5", + "claude-haiku-4": "Haiku 4", + "claude-3-7-sonnet": "Sonnet 3.7", + "claude-3-5-sonnet": "Sonnet 3.5", + "claude-3-5-haiku": "Haiku 3.5", + "claude-3-opus": "Opus 3", + "claude-3-sonnet": "Sonnet 3", + "claude-3-haiku": "Haiku 3", +} +"""Human-readable labels for the canonical model ids (no dated suffixes).""" + + +def is_valid_model(name: str) -> bool: + """Return ``True`` if `name` is a known alias or a known model id. + + A `False` return doesn't necessarily mean `claude` will reject the + value — Anthropic may have added a new model since this module was + refreshed. Use as a hint, not a gate. + """ + return name in ALIASES or name in MODELS_ALL + + +__all__ = [ + "ALIASES", + "CLAUDE_VERSION", + "DISPLAY_NAMES", + "MODELS_ALL", + "MODELS_CURRENT", + "MODELS_LEGACY", + "is_valid_model", +] diff --git a/src/claude_code_api/normalizer.py b/src/claude_code_api/normalizer.py new file mode 100644 index 0000000..082102f --- /dev/null +++ b/src/claude_code_api/normalizer.py @@ -0,0 +1,190 @@ +"""JSONL record -> typed `Event`. + +A pure function. Stateless, side-effect-free, and unaware of the file the +record came from. Higher layers call `normalize(record)` on every line: + +- a typed `Event` instance — surface it to the consumer; +- ``None`` — filtered out (forward-compat unknown type, bookkeeping record, + meta caveat) — drop silently; +- `MessageParseError` — the record claimed to be of a type we handle but + was missing fields we cannot fabricate. + +Filter policy: only the records that carry consumer-visible signal are +mapped (`user`, `assistant`, and the `system.subtype=turn_duration` +heartbeat). Everything else is bookkeeping and gets dropped. +""" + +from __future__ import annotations + +from typing import Any + +from claude_code_api.errors import MessageParseError +from claude_code_api.events import ( + AssistantMessage, + ContentBlock, + Event, + SystemMessage, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + +_BOOKKEEPING_TYPES: frozenset[str] = frozenset( + { + "attachment", + "file-history-snapshot", + "last-prompt", + "ai-title", + "permission-mode", + "queue-operation", + } +) + +_BOOKKEEPING_SYSTEM_SUBTYPES: frozenset[str] = frozenset( + {"local_command", "stop_hook_summary"} +) + + +def normalize( + record: dict[str, Any], *, include_meta_user: bool = False +) -> Event | None: + """Map one JSONL record to a typed event, or ``None`` if filtered. + + Args: + record: parsed JSON object from a JSONL line. + include_meta_user: when ``True``, user records with ``isMeta=True`` + (local-command caveats claude injects) are emitted instead of + dropped. Off by default — those records are not part of the + real conversation history. + + Raises: + MessageParseError: the record's `type` is one we handle but it is + missing fields needed to construct the event. + """ + if not isinstance(record, dict): + msg = f"record must be a dict, got {type(record).__name__}" + raise MessageParseError(msg, record) + + record_type = record.get("type") + if record_type is None: + msg = "record missing 'type' field" + raise MessageParseError(msg, record) + + if record_type in _BOOKKEEPING_TYPES: + return None + + if record_type == "user": + return _parse_user(record, include_meta=include_meta_user) + + if record_type == "assistant": + return _parse_assistant(record) + + if record_type == "system": + return _parse_system(record) + + return None + + +def _parse_user(record: dict[str, Any], *, include_meta: bool) -> UserMessage | None: + if record.get("isMeta") and not include_meta: + return None + + try: + raw_content = record["message"]["content"] + except KeyError as exc: + msg = f"user record missing required field: {exc}" + raise MessageParseError(msg, record) from exc + + content: str | list[ContentBlock] + if isinstance(raw_content, str): + content = raw_content + elif isinstance(raw_content, list): + content = [_parse_block(block, record) for block in raw_content] + else: + got = type(raw_content).__name__ + msg = f"user record content must be str or list, got {got}" + raise MessageParseError(msg, record) + + return UserMessage( + content=content, + uuid=record.get("uuid"), + session_id=record.get("sessionId"), + parent_uuid=record.get("parentUuid"), + ) + + +def _parse_assistant(record: dict[str, Any]) -> AssistantMessage: + try: + message = record["message"] + raw_content = message["content"] + model = message["model"] + except KeyError as exc: + msg = f"assistant record missing required field: {exc}" + raise MessageParseError(msg, record) from exc + + if not isinstance(raw_content, list): + msg = f"assistant content must be a list, got {type(raw_content).__name__}" + raise MessageParseError(msg, record) + + content = [_parse_block(block, record) for block in raw_content] + + return AssistantMessage( + content=content, + model=model, + stop_reason=message.get("stop_reason"), + usage=message.get("usage"), + message_id=message.get("id"), + session_id=record.get("sessionId"), + uuid=record.get("uuid"), + parent_uuid=record.get("parentUuid"), + ) + + +def _parse_system(record: dict[str, Any]) -> SystemMessage | None: + subtype = record.get("subtype") + if subtype is None: + msg = "system record missing 'subtype'" + raise MessageParseError(msg, record) + if subtype in _BOOKKEEPING_SYSTEM_SUBTYPES: + return None + return SystemMessage( + subtype=subtype, + data=record, + session_id=record.get("sessionId"), + uuid=record.get("uuid"), + ) + + +def _parse_block(block: Any, record: dict[str, Any]) -> ContentBlock: + if not isinstance(block, dict): + msg = f"content block must be a dict, got {type(block).__name__}" + raise MessageParseError(msg, record) + block_type = block.get("type") + try: + if block_type == "text": + return TextBlock(text=block["text"]) + if block_type == "thinking": + return ThinkingBlock( + thinking=block["thinking"], signature=block["signature"] + ) + if block_type == "tool_use": + return ToolUseBlock( + id=block["id"], name=block["name"], input=block["input"] + ) + if block_type == "tool_result": + return ToolResultBlock( + tool_use_id=block["tool_use_id"], + content=block.get("content"), + is_error=block.get("is_error"), + ) + except KeyError as exc: + msg = f"{block_type} block missing required field: {exc}" + raise MessageParseError(msg, record) from exc + + msg = f"unknown content block type: {block_type!r}" + raise MessageParseError(msg, record) + + +__all__ = ["normalize"] diff --git a/src/claude_code_api/paths.py b/src/claude_code_api/paths.py new file mode 100644 index 0000000..1a65e27 --- /dev/null +++ b/src/claude_code_api/paths.py @@ -0,0 +1,113 @@ +"""Helpers for locating `claude` JSONL session files. + +`claude` stores per-session transcripts at +`~/.claude/projects//.jsonl`, where `project_key` is +the absolute cwd with every non-alphanumeric character (other than `-`) +replaced by `-`. So `/Users/h/.t3/worktrees/foo` becomes +`-Users-h--t3-worktrees-foo`. + +The encoding is intentionally lossy (existing `-` is preserved) but matches +what claude writes for every cwd inspected. None of these helpers touch the +filesystem except `find_jsonl_by_session_id`. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + +_KEY_SAFE_RE = re.compile(r"[^A-Za-z0-9-]") + +_PROJECTS_DIRNAME = "projects" +_CLAUDE_HOME_DIRNAME = ".claude" + + +def claude_home(home: str | os.PathLike[str] | None = None) -> Path: + """Return the `~/.claude` directory; honors `$HOME` via `Path.home()`.""" + base = Path(home) if home is not None else Path.home() + return base / _CLAUDE_HOME_DIRNAME + + +def projects_root(home: str | os.PathLike[str] | None = None) -> Path: + """Return `~/.claude/projects`.""" + return claude_home(home) / _PROJECTS_DIRNAME + + +def encode_project_key(cwd: str | os.PathLike[str]) -> str: + """Encode an absolute cwd into the directory name claude uses on disk. + + Rules: + + - `cwd` must be absolute; relative paths raise `ValueError`. + - The leading `/` becomes `-`, so the key always starts with `-`. + - Every char outside `[A-Za-z0-9-]` becomes `-`. Adjacent specials + produce adjacent dashes — matches what claude writes. + + Symlinks are not resolved; claude stores the literal invocation path. + """ + raw = os.fspath(cwd) + if not raw: + msg = "cwd must not be empty" + raise ValueError(msg) + if not Path(raw).is_absolute(): + msg = f"cwd must be absolute, got {raw!r}" + raise ValueError(msg) + return _KEY_SAFE_RE.sub("-", raw) + + +def session_dir( + cwd: str | os.PathLike[str], *, home: str | os.PathLike[str] | None = None +) -> Path: + """Return the directory that holds JSONL session files for `cwd`.""" + return projects_root(home) / encode_project_key(cwd) + + +def resolve_jsonl_path( + cwd: str | os.PathLike[str], + session_id: str, + *, + home: str | os.PathLike[str] | None = None, +) -> Path: + """Return the canonical JSONL path for `(cwd, session_id)`. + + Does not check existence — both higher layers (watcher reads, injection + writes) want to be able to compute this path before the file exists. + """ + if not session_id: + msg = "session_id must not be empty" + raise ValueError(msg) + return session_dir(cwd, home=home) / f"{session_id}.jsonl" + + +def find_jsonl_by_session_id( + session_id: str, *, home: str | os.PathLike[str] | None = None +) -> Path | None: + """Search `~/.claude/projects/**/.jsonl`. + + Useful as a sanity check when the cwd-derived key seems wrong. Returns + the first match, or `None` if no session file with that id exists. + """ + if not session_id: + msg = "session_id must not be empty" + raise ValueError(msg) + root = projects_root(home) + if not root.is_dir(): + return None + for path in root.glob(f"*/{session_id}.jsonl"): + return path + return None + + +__all__: Iterable[str] = ( + "claude_home", + "encode_project_key", + "find_jsonl_by_session_id", + "projects_root", + "resolve_jsonl_path", + "session_dir", +) diff --git a/src/claude_code_api/pty.py b/src/claude_code_api/pty.py new file mode 100644 index 0000000..ba0d504 --- /dev/null +++ b/src/claude_code_api/pty.py @@ -0,0 +1,389 @@ +"""PTY-driven `claude` subprocess. + +`PtyClaudeProcess` owns the lifecycle of one long-running interactive `claude` +under a pseudo-TTY. Higher layers consume events from the JSONL session file, +not from this process's PTY output — but the PTY must still be drained +continuously so the child does not block on a full kernel buffer. A background +drain thread handles that; raw output is exposed only for smoke tests and an +optional callback for debug consumers. + +This module knows nothing about turns, JSONL, or event normalization. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import errno +import os +import select +import signal +import threading +import uuid +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from typing import Self + +from ptyprocess import PtyProcess + +from claude_code_api.errors import CLINotFoundError + +PtyOutputCallback = Callable[[bytes], None] + +_PROVIDER_ENV_VARS: tuple[str, ...] = ( + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", +) + +_VALID_PERMISSION_MODES: frozenset[str] = frozenset( + {"acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"} +) + +_DEFAULT_DRAIN_CHUNK = 65536 +_DEFAULT_OUTPUT_BUFFER_CAP = 1_000_000 + + +@dataclass(frozen=True) +class PtyProcessOptions: + """Configuration for a single PTY-spawned `claude` interactive process. + + `cwd` is required because it determines the JSONL project key used by + higher layers. `session_id` is auto-generated as a UUID4 if omitted. + """ + + cwd: str | os.PathLike[str] + session_id: str | None = None + resume_session_id: str | None = None + model: str | None = None + system_prompt: str | None = None + append_system_prompt: str | None = None + allowed_tools: tuple[str, ...] = () + disallowed_tools: tuple[str, ...] = () + mcp_config: tuple[str, ...] = () + add_dir: tuple[str, ...] = () + permission_mode: str = "bypassPermissions" + dangerously_skip_permissions: bool = False + effort: str | None = None + settings: str | None = None + executable: str = "claude" + extra_args: tuple[str, ...] = () + term: str = "xterm-256color" + dimensions: tuple[int, int] = (24, 80) + preserve_provider_env: bool = False + extra_env: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + if ( + not self.dangerously_skip_permissions + and self.permission_mode not in _VALID_PERMISSION_MODES + ): + msg = ( + f"invalid permission_mode={self.permission_mode!r}; " + f"expected one of {sorted(_VALID_PERMISSION_MODES)}" + ) + raise ValueError(msg) + rows, cols = self.dimensions + if rows <= 0 or cols <= 0: + msg = f"dimensions must be positive, got {self.dimensions!r}" + raise ValueError(msg) + if self.resume_session_id is not None and self.session_id is not None: + msg = "set either session_id or resume_session_id, not both" + raise ValueError(msg) + + +def build_argv(opts: PtyProcessOptions, session_id: str) -> list[str]: + """Materialize CLI argv for `claude` interactive mode. + + Subscription-mode TUI must NOT pass `--print`, `--output-format`, or + `--input-format` — they either force headless mode or are silently + ignored by interactive claude. + + When `opts.resume_session_id` is set, emit `--resume ` instead of + `--session-id ` — claude rejects the two flags together unless + `--fork-session` is also passed, which would branch the session into a + new JSONL. + """ + if opts.resume_session_id is not None: + argv: list[str] = [opts.executable, "--resume", opts.resume_session_id] + else: + argv = [opts.executable, "--session-id", session_id] + if opts.dangerously_skip_permissions: + argv.append("--dangerously-skip-permissions") + else: + argv += ["--permission-mode", opts.permission_mode] + if opts.model: + argv += ["--model", opts.model] + if opts.system_prompt is not None: + argv += ["--system-prompt", opts.system_prompt] + if opts.append_system_prompt is not None: + argv += ["--append-system-prompt", opts.append_system_prompt] + if opts.allowed_tools: + argv += ["--allowedTools", ",".join(opts.allowed_tools)] + if opts.disallowed_tools: + argv += ["--disallowedTools", ",".join(opts.disallowed_tools)] + for cfg in opts.mcp_config: + argv += ["--mcp-config", cfg] + if opts.add_dir: + argv += ["--add-dir", *opts.add_dir] + if opts.effort: + argv += ["--effort", opts.effort] + if opts.settings: + argv += ["--settings", opts.settings] + argv.extend(opts.extra_args) + return argv + + +def build_env( + opts: PtyProcessOptions, base: Mapping[str, str] | None = None +) -> dict[str, str]: + """Build the env for the subprocess. + + Starts from `base` (defaults to `os.environ`), optionally strips the + three Anthropic provider env vars so the CLI uses OAuth/subscription + auth, and sets `TERM` / `NO_COLOR` for a predictable TUI surface. + """ + env = dict(base if base is not None else os.environ) + if not opts.preserve_provider_env: + for name in _PROVIDER_ENV_VARS: + env.pop(name, None) + env["TERM"] = opts.term + env["NO_COLOR"] = "1" + env.update(opts.extra_env) + return env + + +class PtyClaudeProcess: + """A live `claude` interactive process under a PTY. + + Public lifecycle: + proc = PtyClaudeProcess(opts) + await proc.start() + await proc.write("hi") + await proc.terminate() # SIGTERM, then SIGKILL after grace + + A background daemon thread drains PTY output continuously to prevent + the child blocking on a full pty buffer. Captured output is available + via `captured_output()` (capped at ~1MB) and optionally streamed to + `on_pty_output`. Higher layers should ignore PTY output and read + JSONL. + """ + + def __init__( + self, + options: PtyProcessOptions, + *, + on_pty_output: PtyOutputCallback | None = None, + output_buffer_cap: int = _DEFAULT_OUTPUT_BUFFER_CAP, + ) -> None: + self._opts = options + self._on_output = on_pty_output + self._output_buffer_cap = output_buffer_cap + + if options.resume_session_id is not None: + self._session_id = options.resume_session_id + else: + self._session_id = options.session_id or str(uuid.uuid4()) + self._argv: list[str] = build_argv(options, self._session_id) + self._env: dict[str, str] = build_env(options) + + self._pty: PtyProcess | None = None + self._drain_thread: threading.Thread | None = None + self._drain_stop = threading.Event() + self._output_lock = threading.Lock() + self._output_buffer = bytearray() + + @property + def session_id(self) -> str: + return self._session_id + + @property + def argv(self) -> list[str]: + return list(self._argv) + + @property + def env(self) -> dict[str, str]: + return dict(self._env) + + @property + def cwd(self) -> str: + return os.fspath(self._opts.cwd) + + @property + def pid(self) -> int | None: + return self._pty.pid if self._pty is not None else None + + def is_alive(self) -> bool: + return self._pty is not None and self._pty.isalive() + + def captured_output(self) -> bytes: + """Snapshot of the rolling PTY output buffer (capped, oldest dropped).""" + with self._output_lock: + return bytes(self._output_buffer) + + async def start(self) -> None: + """Spawn the child synchronously on the main thread. + + ptyprocess uses `pty.openpty()` + `os.forkpty()`; forking from a + worker thread on macOS can leave the child in an unstable state. + The fork itself is microsecond-scale. + """ + if self._pty is not None: + msg = "PtyClaudeProcess.start() called twice" + raise RuntimeError(msg) + + try: + self._pty = PtyProcess.spawn( + self._argv, + cwd=self.cwd, + env=self._env, + echo=False, + dimensions=self._opts.dimensions, + ) + except FileNotFoundError as exc: + raise CLINotFoundError(executable=self._opts.executable) from exc + self._drain_stop.clear() + self._drain_thread = threading.Thread( + target=self._drain_loop, + name=f"pty-drain-{self._session_id[:8]}", + daemon=True, + ) + self._drain_thread.start() + + def _drain_loop(self) -> None: + pty = self._pty + if pty is None: + return + fd = pty.fileno() + while not self._drain_stop.is_set(): + try: + ready, _, _ = select.select([fd], [], [], 0.1) + except (OSError, ValueError): + break + if not ready: + continue + try: + data = os.read(fd, _DEFAULT_DRAIN_CHUNK) + except OSError as exc: + if exc.errno in (errno.EIO, errno.EBADF): + break + if self._drain_stop.wait(0.05): + break + continue + if not data: + break + cb = self._on_output + if cb is not None: + with contextlib.suppress(Exception): + cb(data) + with self._output_lock: + self._output_buffer.extend(data) + overflow = len(self._output_buffer) - self._output_buffer_cap + if overflow > 0: + del self._output_buffer[:overflow] + + async def write(self, data: str | bytes, *, newline: bool = True) -> int: + r"""Write bytes to the child's stdin. + + Strings are UTF-8 encoded. When `newline=True` (the default) the + payload is wrapped in xterm bracketed-paste markers (`ESC [ 200 ~` + ... `ESC [ 201 ~`) and followed by a carriage return — that is the + Enter-key keycode interactive `claude` expects. + + Without the bracketed-paste framing, the TUI heuristically treats + bursts longer than ~63 bytes as a paste and *buffers* them in the + input box without submitting; the trailing `\r` is then absorbed + as a newline inside the box rather than acting as Submit. + Bracketed paste makes the framing explicit for any length payload. + + Callers that need raw byte streaming (e.g. arrow keys, individual + keypresses) pass `newline=False` and write the framing themselves. + """ + if self._pty is None: + msg = "PtyClaudeProcess not started" + raise RuntimeError(msg) + payload = data.encode("utf-8") if isinstance(data, str) else bytes(data) + if newline: + if payload.endswith(b"\r"): + payload = payload[:-1] + payload = b"\x1b[200~" + payload + b"\x1b[201~\r" + pty = self._pty + return await asyncio.to_thread(pty.write, payload) + + async def send_control(self, char: str) -> None: + """Send a control character (e.g. 'c' for Ctrl-C, 'd' for Ctrl-D).""" + if self._pty is None: + msg = "PtyClaudeProcess not started" + raise RuntimeError(msg) + pty = self._pty + await asyncio.to_thread(pty.sendcontrol, char) + + async def wait(self) -> int | None: + """Block until the child exits; return its exit status.""" + if self._pty is None: + return None + pty = self._pty + return await asyncio.to_thread(pty.wait) + + async def terminate(self, *, grace: float = 5.0) -> int | None: + """SIGTERM → wait up to `grace` seconds → SIGKILL ladder.""" + if self._pty is None: + return None + pty = self._pty + if pty.isalive(): + with contextlib.suppress(OSError): + pty.kill(signal.SIGTERM) + deadline = asyncio.get_running_loop().time() + grace + while pty.isalive() and asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(0.05) + if pty.isalive(): + with contextlib.suppress(OSError): + pty.kill(signal.SIGKILL) + return await self._reap() + + async def kill(self) -> int | None: + """Immediate SIGKILL with no grace period.""" + if self._pty is None: + return None + pty = self._pty + if pty.isalive(): + with contextlib.suppress(OSError): + pty.kill(signal.SIGKILL) + return await self._reap() + + async def _reap(self) -> int | None: + pty = self._pty + if pty is None: + return None + exit_status = await asyncio.to_thread(pty.wait) + self._drain_stop.set() + thread = self._drain_thread + if thread is not None and thread.is_alive(): + await asyncio.to_thread(thread.join, 1.0) + with contextlib.suppress(OSError): + pty.close(force=True) + return exit_status + + async def aclose(self) -> int | None: + """Idempotent shutdown — terminate if alive, otherwise reap.""" + if self._pty is None: + return None + if self._pty.isalive(): + return await self.terminate() + return await self._reap() + + async def __aenter__(self) -> Self: + await self.start() + return self + + async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None: + await self.aclose() + + +__all__: Iterable[str] = ( + "PtyClaudeProcess", + "PtyOutputCallback", + "PtyProcessOptions", + "build_argv", + "build_env", +) diff --git a/src/claude_code_api/py.typed b/src/claude_code_api/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/claude_code_api/turn.py b/src/claude_code_api/turn.py new file mode 100644 index 0000000..db9eca4 --- /dev/null +++ b/src/claude_code_api/turn.py @@ -0,0 +1,320 @@ +"""Per-turn orchestration. + +`TurnManager` glues the lower layers into the per-turn loop: send a user +prompt into the PTY, tail the JSONL until the model finishes, hand back +typed events along the way, and synthesize a `ResultMessage` at the turn +boundary. + +Turn-end detection: + +- An `assistant` record with `stop_reason ∈ {end_turn, max_tokens, + stop_sequence, refusal}` closes the turn. By default we return as soon + as this terminal record is yielded. +- `stop_reason="tool_use"` does **not** close the turn. In TUI mode claude + orchestrates the tool-use loop itself; we keep streaming and wait for + the next terminal `assistant`. +- Setting `wait_for_turn_duration=True` adds one extra wait: after the + terminal assistant we keep iterating until a + `system.subtype=turn_duration` heartbeat arrives. + +JSONL has no native `result` record, so we synthesize one from the +terminal assistant's `usage` plus the `durationMs` carried by the +`turn_duration` heartbeat (when available). +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncIterator, Callable, Iterable +from typing import TYPE_CHECKING, Any, Self + +from claude_code_api.errors import ( + BackendError, + MessageParseError, + ProcessError, + SessionError, + classify_pty_failure, +) +from claude_code_api.events import AssistantMessage, Event, ResultMessage, SystemMessage +from claude_code_api.normalizer import normalize +from claude_code_api.watcher import JsonlRecord, JsonlWatcher + +if TYPE_CHECKING: + from claude_code_api.pty import PtyClaudeProcess + +_TERMINAL_STOP_REASONS: frozenset[str] = frozenset( + {"end_turn", "max_tokens", "stop_sequence", "refusal"} +) + +_DEFAULT_FILE_WAIT_TIMEOUT = 30.0 +_DEFAULT_TURN_DURATION_TIMEOUT = 5.0 +_DEFAULT_STARTUP_DELAY = 1.0 + + +ParseErrorCallback = Callable[[MessageParseError, JsonlRecord], None] + + +class TurnManager: + """Drive one turn at a time over a long-lived PTY + JSONL pair. + + The manager does NOT own the lifetime of the watcher's start offset — + `JsonlWatcher` tracks that internally, so re-using the same watcher + across turns naturally picks up where the previous turn left off. The + manager DOES own the PTY lifecycle in the common case (`async with` / + `aclose()`); callers that want to share a PTY across managers can + construct with `owns_pty=False`. + + Usage: + pty = PtyClaudeProcess(opts) + path = resolve_jsonl_path(pty.cwd, pty.session_id) + watcher = JsonlWatcher(path) + async with TurnManager(pty, watcher) as tm: + async for event in tm.send_user_message("say hi"): + print(event) + """ + + def __init__( + self, + pty: PtyClaudeProcess, + watcher: JsonlWatcher, + *, + wait_for_turn_duration: bool = False, + include_meta_user: bool = False, + file_wait_timeout: float | None = _DEFAULT_FILE_WAIT_TIMEOUT, + turn_duration_timeout: float | None = _DEFAULT_TURN_DURATION_TIMEOUT, + startup_delay: float = _DEFAULT_STARTUP_DELAY, + on_parse_error: ParseErrorCallback | None = None, + owns_pty: bool = True, + ) -> None: + if file_wait_timeout is not None and file_wait_timeout < 0: + msg = f"file_wait_timeout must be non-negative, got {file_wait_timeout!r}" + raise ValueError(msg) + if turn_duration_timeout is not None and turn_duration_timeout < 0: + msg = f"turn_duration_timeout must be non-negative, got {turn_duration_timeout!r}" # noqa: E501 + raise ValueError(msg) + if startup_delay < 0: + msg = f"startup_delay must be non-negative, got {startup_delay!r}" + raise ValueError(msg) + + self._pty = pty + self._watcher = watcher + self._wait_for_turn_duration = wait_for_turn_duration + self._include_meta_user = include_meta_user + self._file_wait_timeout = file_wait_timeout + self._turn_duration_timeout = turn_duration_timeout + self._startup_delay = startup_delay + self._on_parse_error = on_parse_error + self._owns_pty = owns_pty + + self._started = False + self._turn_count = 0 + self._turn_in_progress = False + + @property + def pty(self) -> PtyClaudeProcess: + return self._pty + + @property + def watcher(self) -> JsonlWatcher: + return self._watcher + + @property + def turn_count(self) -> int: + """Number of turns completed (or in progress) so far.""" + return self._turn_count + + async def start(self) -> None: + """Spawn the PTY and let claude's TUI settle. Idempotent.""" + if self._started: + return + await self._pty.start() + if self._startup_delay > 0: + await asyncio.sleep(self._startup_delay) + self._started = True + + async def send_user_message(self, text: str) -> AsyncIterator[Event]: + """Send `text` as a user prompt and stream events until turn-end. + + Async generator: yields every typed event normalized from new JSONL + records (user/assistant/system) and, as its final value, a + synthesized `ResultMessage`. + """ + if not self._started: + msg = "TurnManager.send_user_message() called before start()" + raise RuntimeError(msg) + if self._turn_in_progress: + msg = "send_user_message() called while a turn is in progress" + raise RuntimeError(msg) + self._turn_in_progress = True + self._turn_count += 1 + try: + try: + await self._pty.write(text) + except OSError as exc: + raise self._classify_pty_failure( + fallback_message="claude process not accepting input" + ) from exc + + if not self._watcher.path.exists(): + try: + await self._watcher.wait_for_file(timeout=self._file_wait_timeout) + except TimeoutError as exc: + raise self._classify_pty_failure( + fallback_cls=SessionError, + fallback_message=( + f"JSONL file did not appear within " + f"{self._file_wait_timeout}s: {self._watcher.path}" + ), + ) from exc + + terminal_assistant: AssistantMessage | None = None + terminal_seen_at: float | None = None + + poll = self._watcher.poll_interval + loop = asyncio.get_running_loop() + + while True: + records = await self._watcher.read_once() + if not records: + if ( + terminal_assistant is not None + and terminal_seen_at is not None + and self._turn_duration_timeout is not None + and loop.time() - terminal_seen_at > self._turn_duration_timeout + ): + yield self._synthesize_result(terminal_assistant, None) + return + if terminal_assistant is None and not self._pty_is_alive(): + raise self._classify_pty_failure( + fallback_message=( + "claude process exited before a terminal " + "assistant message was emitted" + ) + ) + await asyncio.sleep(poll) + continue + + for rec in records: + try: + event = normalize( + rec, include_meta_user=self._include_meta_user + ) + except MessageParseError as exc: + if self._on_parse_error is not None: + with contextlib.suppress(Exception): + self._on_parse_error(exc, rec) + continue + if event is None: + continue + yield event + + if isinstance(event, AssistantMessage): + if event.stop_reason in _TERMINAL_STOP_REASONS: + terminal_assistant = event + terminal_seen_at = loop.time() + if not self._wait_for_turn_duration: + yield self._synthesize_result(terminal_assistant, None) + return + elif ( + isinstance(event, SystemMessage) + and event.subtype == "turn_duration" + and terminal_assistant is not None + ): + duration_ms = _extract_duration_ms(event.data) + yield self._synthesize_result(terminal_assistant, duration_ms) + return + finally: + self._turn_in_progress = False + + def _pty_is_alive(self) -> bool: + """Best-effort liveness check. Test fakes may lack `is_alive()`.""" + is_alive = getattr(self._pty, "is_alive", None) + if is_alive is None: + return True + try: + return bool(is_alive()) + except Exception: + return True + + def _captured_pty_output(self) -> bytes: + """Best-effort snapshot of the PTY drain buffer.""" + captured = getattr(self._pty, "captured_output", None) + if captured is None: + return b"" + try: + value = captured() + except Exception: + return b"" + if isinstance(value, bytearray): + return bytes(value) + return value if isinstance(value, bytes) else b"" + + def _classify_pty_failure( + self, + *, + fallback_cls: type[BackendError] = ProcessError, + fallback_message: str = "claude process failed", + ) -> BackendError: + """Build the typed exception that fits the current PTY state.""" + captured = self._captured_pty_output() + cls = classify_pty_failure(captured) or fallback_cls + + if issubclass(cls, ProcessError): + exit_code = getattr(self._pty, "_pty", None) + real_exit: int | None = None + inner = getattr(exit_code, "exitstatus", None) + if isinstance(inner, int): + real_exit = inner + stderr_text = ( + captured.decode("utf-8", errors="replace") if captured else None + ) + return cls(fallback_message, exit_code=real_exit, stderr=stderr_text) + return cls(fallback_message) + + def _synthesize_result( + self, assistant: AssistantMessage, duration_ms: int | None + ) -> ResultMessage: + return ResultMessage( + subtype="success", + duration_ms=duration_ms if duration_ms is not None else 0, + num_turns=self._turn_count, + session_id=self._pty.session_id, + is_error=False, + stop_reason=assistant.stop_reason, + usage=assistant.usage, + ) + + async def aclose(self) -> None: + """Shut down — terminate the PTY if we own it.""" + if self._owns_pty: + await self._pty.aclose() + + async def __aenter__(self) -> Self: + await self.start() + return self + + async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None: + await self.aclose() + + +def _extract_duration_ms(data: dict[str, Any]) -> int | None: + """Pull a turn-duration value out of a `system.subtype=turn_duration` record.""" + for key in ("durationMs", "duration_ms"): + value = data.get(key) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + nested = data.get("data") + if isinstance(nested, dict): + for key in ("durationMs", "duration_ms"): + value = nested.get(key) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + return None + + +__all__: Iterable[str] = ("ParseErrorCallback", "TurnManager") diff --git a/src/claude_code_api/watcher.py b/src/claude_code_api/watcher.py new file mode 100644 index 0000000..2e7e09b --- /dev/null +++ b/src/claude_code_api/watcher.py @@ -0,0 +1,179 @@ +"""Polling tail of a `claude` JSONL session file. + +`JsonlWatcher` watches a single `.jsonl` and yields each appended +record as a parsed `dict`. It is intentionally dumb about semantics — turning +records into Anthropic events lives in `normalizer.py`, orchestrating turns +lives in `turn.py`. + +Guarantees: + +- the file may not exist yet when `tail()` starts — the watcher waits; +- bytes are read incrementally from a tracked offset, so reopening on every + poll is cheap (no full re-scan); +- a record split across two polls is held in an internal byte buffer until + the trailing newline arrives; +- a malformed JSON line is delegated to an optional callback and otherwise + dropped — one bad record must not stall the stream. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import errno +import json +from collections.abc import AsyncIterator, Callable, Iterable +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import os + +JsonlRecord = dict[str, Any] +ParseErrorCallback = Callable[[bytes, json.JSONDecodeError], None] + +_DEFAULT_POLL_INTERVAL = 0.1 +_DEFAULT_READ_CHUNK = 65536 + + +class JsonlWatcher: + """Tail one JSONL file. One watcher per session. + + Usage: + watcher = JsonlWatcher(path) + async for record in watcher.tail(): + handle(record) + + `tail()` is an unbounded async iterator — it never returns on its own. + Stop it by cancelling the consuming task (or by breaking out of the + `async for`, which propagates `GeneratorExit` and lets the watcher + clean up its internal buffer). + """ + + def __init__( + self, + path: str | os.PathLike[str], + *, + poll_interval: float = _DEFAULT_POLL_INTERVAL, + start_offset: int = 0, + on_parse_error: ParseErrorCallback | None = None, + read_chunk: int = _DEFAULT_READ_CHUNK, + ) -> None: + if poll_interval <= 0: + msg = f"poll_interval must be positive, got {poll_interval!r}" + raise ValueError(msg) + if start_offset < 0: + msg = f"start_offset must be non-negative, got {start_offset!r}" + raise ValueError(msg) + if read_chunk <= 0: + msg = f"read_chunk must be positive, got {read_chunk!r}" + raise ValueError(msg) + + self._path = Path(path) + self._poll_interval = poll_interval + self._on_parse_error = on_parse_error + self._read_chunk = read_chunk + self._offset = start_offset + self._line_buffer = bytearray() + + @property + def path(self) -> Path: + return self._path + + @property + def offset(self) -> int: + """Byte offset at which the next read will resume.""" + return self._offset + + @property + def poll_interval(self) -> float: + return self._poll_interval + + async def wait_for_file(self, *, timeout: float | None = None) -> None: + """Block until `path` exists. + + Polls at the configured interval. Raises `TimeoutError` if the file + does not appear before `timeout` seconds elapse. `timeout=None` + means wait forever (callers should rely on task cancellation). + """ + if timeout is not None and timeout < 0: + msg = f"timeout must be non-negative, got {timeout!r}" + raise ValueError(msg) + loop = asyncio.get_running_loop() + deadline = None if timeout is None else loop.time() + timeout + while not self._path.exists(): + if deadline is not None and loop.time() >= deadline: + msg = f"JSONL file did not appear within {timeout}s: {self._path}" + raise TimeoutError(msg) + await asyncio.sleep(self._poll_interval) + + async def read_once(self) -> list[JsonlRecord]: + """Single non-blocking pass: drain all currently available records. + + Returns `[]` when the file does not exist or no complete record has + been appended since the last call. Updates internal offset and the + partial-line buffer either way. + """ + return await asyncio.to_thread(self._read_available) + + async def tail(self) -> AsyncIterator[JsonlRecord]: + """Yield records as they appear. Runs until cancelled.""" + while True: + if not self._path.exists(): + await asyncio.sleep(self._poll_interval) + continue + records = await asyncio.to_thread(self._read_available) + if not records: + await asyncio.sleep(self._poll_interval) + continue + for rec in records: + yield rec + + def _read_available(self) -> list[JsonlRecord]: + try: + stat = self._path.stat() + except FileNotFoundError: + return [] + size = stat.st_size + if size < self._offset: + self._offset = 0 + self._line_buffer.clear() + if size == self._offset: + return [] + + records: list[JsonlRecord] = [] + try: + with self._path.open("rb") as f: + f.seek(self._offset) + while True: + chunk = f.read(self._read_chunk) + if not chunk: + break + self._offset += len(chunk) + self._line_buffer.extend(chunk) + self._drain_buffer_into(records) + except OSError as exc: + if exc.errno == errno.ENOENT: + return records + raise + return records + + def _drain_buffer_into(self, records: list[JsonlRecord]) -> None: + while True: + nl = self._line_buffer.find(b"\n") + if nl == -1: + return + line = bytes(self._line_buffer[:nl]) + del self._line_buffer[: nl + 1] + if not line.strip(): + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError as exc: + cb = self._on_parse_error + if cb is not None: + with contextlib.suppress(Exception): + cb(line, exc) + + +__all__: Iterable[str] = ("JsonlRecord", "JsonlWatcher", "ParseErrorCallback") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_backend.py b/tests/test_backend.py new file mode 100644 index 0000000..0e96240 --- /dev/null +++ b/tests/test_backend.py @@ -0,0 +1,852 @@ +"""Unit + smoke tests for Layer 5 (`ClaudeCodeBackend`). + +Unit tests inject a `FakePty`-backed session factory so we can drive the +dispatch logic end-to-end — fingerprint lookup, fresh spawn vs continuation, +native_jsonl seeding vs concat_message preamble, post-turn fingerprint +stash — without launching `claude`. The smoke test at the bottom spawns +the real binary behind `RUN_CLAUDE_SMOKE=1`. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +from pathlib import Path +from typing import Any + +import pytest + +from claude_code_api import ( + AssistantMessage, + BackendOptions, + ClaudeCodeBackend, + ResultMessage, + SessionError, + TextBlock, + UserMessage, +) +from claude_code_api.backend import _LiveSession +from claude_code_api.injection import hash_history +from claude_code_api.paths import resolve_jsonl_path +from claude_code_api.watcher import JsonlWatcher +from claude_code_api.turn import TurnManager + +# --- fakes ----------------------------------------------------------------- + + +class FakePty: + """Records writes and flushes a scripted JSONL batch on each `write()`. + + Reused shape from `test_turn_manager.py` so the contract stays familiar. + Each backend `complete()` call ultimately drives one `write()` on the + underlying PTY, which consumes the next entry in `scripts`. Tests pre-load + the script list with one batch per expected turn. + """ + + def __init__( + self, + jsonl_path: Path, + *, + session_id: str, + scripts: list[list[dict[str, Any]]], + ) -> None: + self.cwd = str(jsonl_path.parent) + self.session_id = session_id + self._jsonl = jsonl_path + self._scripts = scripts + self._write_count = 0 + self.writes: list[str] = [] + self.started = False + self.closed = False + + async def start(self) -> None: + self.started = True + + async def write(self, text: str, *, newline: bool = True) -> int: + self.writes.append(text) + if self._write_count < len(self._scripts): + self._jsonl.parent.mkdir(parents=True, exist_ok=True) + with self._jsonl.open("a", encoding="utf-8") as f: + for rec in self._scripts[self._write_count]: + f.write(json.dumps(rec) + "\n") + self._write_count += 1 + return len(text) + + async def aclose(self) -> None: + self.closed = True + + +def _user_rec(text: str, session_id: str) -> dict[str, Any]: + return { + "type": "user", + "uuid": f"u-{text[:8]}", + "sessionId": session_id, + "parentUuid": None, + "message": {"role": "user", "content": text}, + } + + +def _assistant_rec( + text: str, + session_id: str, + *, + stop_reason: str = "end_turn", +) -> dict[str, Any]: + return { + "type": "assistant", + "uuid": f"a-{text[:8]}", + "sessionId": session_id, + "parentUuid": None, + "message": { + "id": "msg_x", + "role": "assistant", + "model": "claude-test", + "content": [{"type": "text", "text": text}], + "stop_reason": stop_reason, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + } + + +class FakeFactoryHarness: + """Builds the `_session_factory` callable the backend wants, while + also tracking every session spawned so tests can inspect them. + + Each call to the factory pops the next FakePty script batch off the + queue and wires a real `TurnManager` + `JsonlWatcher` around it — that + way we exercise the same code path real sessions use, only the bottom + layer is faked. + """ + + def __init__(self, scripts_per_session: list[list[list[dict[str, Any]]]]) -> None: + self._scripts = list(scripts_per_session) + self.spawned: list[FakePty] = [] + self.seed_files: list[tuple[Path, bytes]] = [] + + def __call__( + self, + backend: ClaudeCodeBackend, + session_id: str, + resume: bool, + jsonl_path: Path, + start_offset: int, + ) -> Any: + # Reconstruct the test-visible script for THIS session. + if not self._scripts: + raise AssertionError("FakeFactoryHarness ran out of scripts") + scripts = self._scripts.pop(0) + if resume and jsonl_path.exists(): + self.seed_files.append((jsonl_path, jsonl_path.read_bytes())) + fake = FakePty(jsonl_path, session_id=session_id, scripts=scripts) + self.spawned.append(fake) + + watcher = JsonlWatcher(jsonl_path, poll_interval=0.01, start_offset=start_offset) + tm = TurnManager( + fake, # type: ignore[arg-type] + watcher, + startup_delay=0.0, + file_wait_timeout=2.0, + ) + + async def _start() -> _LiveSession: + await tm.start() + return _LiveSession(pty=fake, watcher=watcher, tm=tm) # type: ignore[arg-type] + + return _start() + + +# --- option / validation tests -------------------------------------------- + + +@pytest.mark.asyncio +async def test_complete_rejects_empty_messages(tmp_path: Path) -> None: + backend = ClaudeCodeBackend(BackendOptions(cwd=str(tmp_path))) + with pytest.raises(ValueError, match="empty"): + async for _ in backend.complete([]): + pass + await backend.aclose() + + +@pytest.mark.asyncio +async def test_complete_rejects_non_user_last_message(tmp_path: Path) -> None: + backend = ClaudeCodeBackend(BackendOptions(cwd=str(tmp_path))) + with pytest.raises(ValueError, match="user"): + async for _ in backend.complete([{"role": "assistant", "content": "hi"}]): + pass + await backend.aclose() + + +@pytest.mark.asyncio +async def test_complete_after_aclose_raises(tmp_path: Path) -> None: + backend = ClaudeCodeBackend(BackendOptions(cwd=str(tmp_path))) + await backend.aclose() + with pytest.raises(RuntimeError, match="closed"): + async for _ in backend.complete([{"role": "user", "content": "hi"}]): + pass + + +# --- single-turn fresh session ------------------------------------------- + + +@pytest.mark.asyncio +async def test_complete_fresh_session_yields_events(tmp_path: Path) -> None: + """One message → spawn a fresh session, run one turn, get events back. + + Because there's no prior history, no seed JSONL gets written. The fake + PTY's `write()` appends a scripted `(user, assistant)` pair to the JSONL + on disk; the real watcher tails it and the real TurnManager closes the + turn on the terminal assistant. + """ + # We need to know the session_id ahead of time? No — let the factory + # pull it from the backend's invocation. The scripts in scripts_per_session + # carry sessionId fields but those are decorative for our purposes — + # the watcher / normalizer don't filter on them. + scripts_per_session = [ + # session 0: + [ + # turn 0 batch (written on first write()) + [ + _user_rec("hi", "S0"), + _assistant_rec("hello there", "S0"), + ], + ], + ] + harness = FakeFactoryHarness(scripts_per_session) + backend = ClaudeCodeBackend( + BackendOptions(cwd=str(tmp_path)), + _session_factory=harness, + ) + + events: list[Any] = [] + async for event in backend.complete([{"role": "user", "content": "hi"}]): + events.append(event) + await backend.aclose() + + assert len(harness.spawned) == 1 + assert harness.spawned[0].writes == ["hi"] + assert any(isinstance(e, UserMessage) for e in events) + assert any(isinstance(e, AssistantMessage) for e in events) + assert isinstance(events[-1], ResultMessage) + assert events[-1].stop_reason == "end_turn" + # No seed was written — first turn has empty prior history. + assert harness.seed_files == [] + + +# --- multi-turn fingerprint reuse ---------------------------------------- + + +@pytest.mark.asyncio +async def test_continuation_reuses_live_session(tmp_path: Path) -> None: + """Second `complete()` whose `messages[:-1]` matches the post-turn + fingerprint of the first call must hit the live session — no new PTY, + no seed file. + """ + scripts_per_session = [ + # session 0 handles BOTH turns (two write() calls). + [ + [_user_rec("hi", "S0"), _assistant_rec("hello there", "S0")], + [_user_rec("again", "S0"), _assistant_rec("hi again", "S0")], + ], + ] + harness = FakeFactoryHarness(scripts_per_session) + backend = ClaudeCodeBackend( + BackendOptions(cwd=str(tmp_path)), + _session_factory=harness, + ) + + events1: list[Any] = [] + async for e in backend.complete([{"role": "user", "content": "hi"}]): + events1.append(e) + + # Build the continuation: client echoes back our synthesized assistant + # in canonical Anthropic shape (list of blocks). + continuation = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": [{"type": "text", "text": "hello there"}]}, + {"role": "user", "content": "again"}, + ] + events2: list[Any] = [] + async for e in backend.complete(continuation): + events2.append(e) + await backend.aclose() + + # Only ONE session was spawned across both turns. + assert len(harness.spawned) == 1 + assert harness.spawned[0].writes == ["hi", "again"] + # Second turn's events are clean (turn_count bookkeeping): + assert isinstance(events2[-1], ResultMessage) + assert events2[-1].num_turns == 2 + + +@pytest.mark.asyncio +async def test_unmatched_history_spawns_new_session_via_native_jsonl( + tmp_path: Path, +) -> None: + """When prior history doesn't match any live session, the backend + seeds a JSONL with that history and spawns a fresh `--resume` session + (native_jsonl default mode). + """ + scripts_per_session = [ + # one session for one turn — the only write() is the new user message + [ + [_user_rec("how are you?", "S0"), _assistant_rec("good", "S0")], + ], + ] + harness = FakeFactoryHarness(scripts_per_session) + backend = ClaudeCodeBackend( + BackendOptions(cwd=str(tmp_path)), + _session_factory=harness, + ) + + # Three messages, no live session in the pool — must seed. + messages = [ + {"role": "user", "content": "remember beaver"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "how are you?"}, + ] + events: list[Any] = [] + async for e in backend.complete(messages): + events.append(e) + await backend.aclose() + + assert len(harness.spawned) == 1 + # Only the LAST user message is sent into the PTY — history went via seed. + assert harness.spawned[0].writes == ["how are you?"] + # A seed file was captured by the harness. + assert len(harness.seed_files) == 1 + _seed_path, seed_bytes = harness.seed_files[0] + seed_lines = [ + json.loads(line) for line in seed_bytes.decode("utf-8").strip().splitlines() + ] + # Two seeded records (one user + one assistant) for the prior turn. + assert [r["type"] for r in seed_lines] == ["user", "assistant"] + assert seed_lines[0]["message"]["content"] == "remember beaver" + assert seed_lines[1]["message"]["content"] == [{"type": "text", "text": "ok"}] + assert isinstance(events[-1], ResultMessage) + + +@pytest.mark.asyncio +async def test_unmatched_history_uses_concat_message_when_configured( + tmp_path: Path, +) -> None: + """In `concat_message` mode the backend does NOT write a seed JSONL — + it concatenates the prior history into the first stdin payload.""" + scripts_per_session = [ + [ + [_user_rec("how are you?", "S0"), _assistant_rec("good", "S0")], + ], + ] + harness = FakeFactoryHarness(scripts_per_session) + backend = ClaudeCodeBackend( + BackendOptions(cwd=str(tmp_path), history_injection_mode="concat_message"), + _session_factory=harness, + ) + + messages = [ + {"role": "user", "content": "remember beaver"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "how are you?"}, + ] + async for _ in backend.complete(messages): + pass + await backend.aclose() + + assert harness.seed_files == [] # no native injection in concat mode + assert len(harness.spawned) == 1 + sent = harness.spawned[0].writes[0] + # The first payload is the concat preamble + the new user prompt. + assert "Previous conversation context:" in sent + assert "[User]: remember beaver" in sent + assert "[Assistant]: ok" in sent + assert "Continue from here. New user message: how are you?" in sent + + +# --- failure handling ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_complete_failure_does_not_stash_broken_session(tmp_path: Path) -> None: + """If the turn iteration raises, the session must be closed and NOT + re-stored under any fingerprint. + """ + + class BrokenFactory: + def __init__(self) -> None: + self.spawned: list[FakePty] = [] + + def __call__( + self, + backend: ClaudeCodeBackend, + session_id: str, + resume: bool, + jsonl_path: Path, + start_offset: int, + ) -> Any: + fake = FakePty(jsonl_path, session_id=session_id, scripts=[]) + self.spawned.append(fake) + watcher = JsonlWatcher(jsonl_path, poll_interval=0.01) + tm = TurnManager( + fake, # type: ignore[arg-type] + watcher, + startup_delay=0.0, + file_wait_timeout=0.05, # fires fast — no JSONL ever appears + ) + + async def _start() -> _LiveSession: + await tm.start() + return _LiveSession(pty=fake, watcher=watcher, tm=tm) # type: ignore[arg-type] + + return _start() + + factory = BrokenFactory() + backend = ClaudeCodeBackend( + BackendOptions(cwd=str(tmp_path)), + _session_factory=factory, + ) + + with pytest.raises(SessionError): + async for _ in backend.complete([{"role": "user", "content": "hi"}]): + pass + + assert backend.live_session_count == 0 + assert factory.spawned[0].closed is True + await backend.aclose() + + +# --- cancellation (Stage 9) ---------------------------------------------- + + +class _HangingFactory: + """Factory whose sessions never produce records — perfect for cancel tests. + + `write()` creates the JSONL (so `wait_for_file()` returns immediately) but + leaves it empty, so `TurnManager.send_user_message` enters its poll loop + and stays there until something cancels it from outside. + """ + + def __init__(self) -> None: + self.spawned: list[FakePty] = [] + + def __call__( + self, + backend: ClaudeCodeBackend, + session_id: str, + resume: bool, + jsonl_path: Path, + start_offset: int, + ) -> Any: + fake = FakePty(jsonl_path, session_id=session_id, scripts=[[]]) + self.spawned.append(fake) + watcher = JsonlWatcher(jsonl_path, poll_interval=0.01) + tm = TurnManager( + fake, # type: ignore[arg-type] + watcher, + startup_delay=0.0, + file_wait_timeout=2.0, + ) + + async def _start() -> _LiveSession: + await tm.start() + return _LiveSession(pty=fake, watcher=watcher, tm=tm) # type: ignore[arg-type] + + return _start() + + +@pytest.mark.asyncio +async def test_cancel_mid_turn_closes_session_and_leaves_pool_empty( + tmp_path: Path, +) -> None: + """task.cancel() on a consumer iterating `complete()` must: + - propagate CancelledError to the consumer, + - tear down the live session (PTY closed via TurnManager.aclose), + - leave the live-session pool empty (broken session is never re-stashed). + """ + factory = _HangingFactory() + backend = ClaudeCodeBackend( + BackendOptions(cwd=str(tmp_path)), + _session_factory=factory, + ) + + started = asyncio.Event() + + async def consumer() -> None: + async for _ in backend.complete([{"role": "user", "content": "hi"}]): + started.set() + started.set() # also signal if iteration ends naturally (shouldn't here) + + task = asyncio.create_task(consumer()) + # Let the turn enter its poll loop. The poll interval is 10ms; 200ms is + # plenty for the FakePty.write() + first read_once() to land. + await asyncio.sleep(0.2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert backend.live_session_count == 0 + assert len(factory.spawned) == 1 + assert factory.spawned[0].closed is True + await backend.aclose() + + +@pytest.mark.asyncio +async def test_cancel_releases_lock_so_next_complete_works(tmp_path: Path) -> None: + """After a cancelled turn, the backend's internal lock must be released + so a subsequent `complete()` can run. We follow up with a normal call + against a healthy session and assert it completes end-to-end. + """ + + class HangThenRespondFactory: + """First spawn hangs (cancel target); second spawn completes a turn.""" + + def __init__(self) -> None: + self._spawn_index = 0 + self.spawned: list[FakePty] = [] + + def __call__( + self, + backend: ClaudeCodeBackend, + session_id: str, + resume: bool, + jsonl_path: Path, + start_offset: int, + ) -> Any: + idx = self._spawn_index + self._spawn_index += 1 + if idx == 0: + scripts: list[list[dict[str, Any]]] = [[]] # hangs + else: + scripts = [ + [ + _user_rec("hi", "S1"), + _assistant_rec("hello", "S1"), + ] + ] + fake = FakePty(jsonl_path, session_id=session_id, scripts=scripts) + self.spawned.append(fake) + watcher = JsonlWatcher(jsonl_path, poll_interval=0.01) + tm = TurnManager( + fake, # type: ignore[arg-type] + watcher, + startup_delay=0.0, + file_wait_timeout=2.0, + ) + + async def _start() -> _LiveSession: + await tm.start() + return _LiveSession(pty=fake, watcher=watcher, tm=tm) # type: ignore[arg-type] + + return _start() + + factory = HangThenRespondFactory() + backend = ClaudeCodeBackend( + BackendOptions(cwd=str(tmp_path)), + _session_factory=factory, + ) + + # First call: cancel mid-stream. + async def consumer() -> None: + async for _ in backend.complete([{"role": "user", "content": "hi"}]): + pass + + task = asyncio.create_task(consumer()) + await asyncio.sleep(0.2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # Second call: must proceed without deadlocking on the lock. + events: list[Any] = [] + async for e in backend.complete([{"role": "user", "content": "hi"}]): + events.append(e) + + assert len(factory.spawned) == 2 + assert factory.spawned[0].closed is True # cancelled session is dead + assert isinstance(events[-1], ResultMessage) + assert events[-1].num_turns == 1 # fresh session, fresh counter + await backend.aclose() + + +# --- mcp_servers materialization ----------------------------------------- + + +def test_mcp_config_argument_writes_temp_file_lazily(tmp_path: Path) -> None: + """`mcp_servers` lifts to a temp `--mcp-config` JSON written on first + access; the file is removed in `aclose()`.""" + backend = ClaudeCodeBackend( + BackendOptions( + cwd=str(tmp_path), + mcp_servers={"echo": {"command": "/bin/echo", "args": []}}, + ) + ) + paths = backend._mcp_config_argument() # type: ignore[attr-defined] + assert len(paths) == 1 + p = Path(paths[0]) + assert p.exists() + body = json.loads(p.read_text()) + assert body == {"mcpServers": {"echo": {"command": "/bin/echo", "args": []}}} + + # Calling again returns the same path; no second file. + paths2 = backend._mcp_config_argument() # type: ignore[attr-defined] + assert paths2 == paths + + # aclose() removes the file. + asyncio.run(backend.aclose()) + assert not p.exists() + + +def test_no_mcp_config_returns_empty_tuple(tmp_path: Path) -> None: + backend = ClaudeCodeBackend(BackendOptions(cwd=str(tmp_path))) + assert backend._mcp_config_argument() == () # type: ignore[attr-defined] + + +# --- post-turn fingerprint key shape ------------------------------------- + + +def test_post_turn_fingerprint_matches_canonical_continuation(tmp_path: Path) -> None: + """Regression: the backend stashes the live session under + hash_history(messages + [synthesized_assistant]) where the synthesized + assistant uses the `[{"type": "text", "text": ...}]` block shape. + + A gateway that echoes that same shape back on the next request must + look up to the same fingerprint. Pin both sides of that contract here. + """ + # Synthesized assistant after one turn yielding "hello there": + synthesized = { + "role": "assistant", + "content": [{"type": "text", "text": "hello there"}], + } + messages_sent = [{"role": "user", "content": "hi"}] + fp_stash = hash_history([*messages_sent, synthesized]) + + next_request_prior = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": [{"type": "text", "text": "hello there"}]}, + ] + fp_lookup = hash_history(next_request_prior) + assert fp_stash == fp_lookup + + +# --- smoke test (real claude) -------------------------------------------- + + +_SMOKE_ENV = "RUN_CLAUDE_SMOKE" + + +@pytest.mark.skipif( + os.environ.get(_SMOKE_ENV) != "1", + reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test", +) +@pytest.mark.asyncio +async def test_smoke_backend_round_trip(tmp_path: Path) -> None: + """End-to-end against real claude through the public API. + + Single `complete()` call with no prior history → fresh session → + yields events. Asserts the same shape contracts the gateway will + rely on: at least one terminal assistant message and a final + `ResultMessage` whose session_id matches the live PTY. + """ + backend = ClaudeCodeBackend( + BackendOptions(cwd=str(tmp_path), dangerously_skip_permissions=True), + ) + + events: list[Any] = [] + try: + async for event in backend.complete([{"role": "user", "content": "say hi"}]): + events.append(event) + finally: + await backend.aclose() + + terminal = next( + ( + e + for e in events + if isinstance(e, AssistantMessage) + and e.stop_reason in {"end_turn", "max_tokens", "stop_sequence", "refusal"} + ), + None, + ) + assert terminal is not None + assert any(isinstance(b, TextBlock) for b in terminal.content) + assert isinstance(events[-1], ResultMessage) + assert events[-1].stop_reason == terminal.stop_reason + + +@pytest.mark.skipif( + os.environ.get(_SMOKE_ENV) != "1", + reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test", +) +@pytest.mark.asyncio +async def test_smoke_backend_native_jsonl_injection(tmp_path: Path) -> None: + """Real claude, real injection: send a 3-message history (no live + session yet), the backend writes a seed JSONL and resumes — the + assistant reply must reference the seeded context. + """ + backend = ClaudeCodeBackend( + BackendOptions(cwd=str(tmp_path), dangerously_skip_permissions=True), + ) + + messages = [ + {"role": "user", "content": "My name is Beaver. Please remember it."}, + {"role": "assistant", "content": "Got it — your name is Beaver."}, + {"role": "user", "content": "What is my name? Answer with just the name, one word."}, + ] + events: list[Any] = [] + try: + async for event in backend.complete(messages): + events.append(event) + finally: + await backend.aclose() + + # The seeded JSONL should be visible on disk under the session path. + # (We can't easily get the session_id back here, but the test of + # correctness is in the reply.) + terminal = next( + ( + e + for e in events + if isinstance(e, AssistantMessage) and e.stop_reason == "end_turn" + ), + None, + ) + assert terminal is not None + text = " ".join(b.text for b in terminal.content if isinstance(b, TextBlock)) + assert "beaver" in text.lower(), f"injection failed to plant context; got {text!r}" + + # Sanity: the file the backend resumed against exists and contains our seed. + session_id = events[-1].session_id # type: ignore[union-attr] + assert isinstance(session_id, str) + jsonl_path = resolve_jsonl_path(str(tmp_path), session_id) + assert jsonl_path.exists() + # The seeded user record's content text is in the file. + assert "My name is Beaver" in jsonl_path.read_text() + + +@pytest.mark.skipif( + os.environ.get(_SMOKE_ENV) != "1", + reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test", +) +@pytest.mark.asyncio +async def test_smoke_cancellation_kills_pty_no_zombie(tmp_path: Path) -> None: + """Smoke 4 (Stage 9): cancel a real long-running turn, assert the PTY + dies cleanly with no zombie left behind. + + Strategy: + - prompt claude with something verbose so the turn stays in flight + long enough for us to cancel mid-stream; + - wrap the spawn through `_session_factory` so we can capture the + live `PtyClaudeProcess` while it's still in flight (the backend + does NOT keep in-flight sessions in `_sessions`); + - cancel the consumer task as soon as we've seen at least one event + (proving the turn really started — otherwise we'd be cancelling a + not-yet-spawned session); + - after the cancel propagates, assert: PTY is dead (no `kill -0`), + pool is empty, and a second `complete()` on the same backend still + works (lock was released). + """ + import signal as _signal + + captured: list[Any] = [] # collected _LiveSession objects + + backend_box: dict[str, ClaudeCodeBackend] = {} + + def capturing_factory( + backend: ClaudeCodeBackend, + session_id: str, + resume: bool, + jsonl_path: Path, + start_offset: int, + ) -> Any: + async def _real() -> Any: + session = await backend._spawn_real_session( # type: ignore[attr-defined] + session_id=session_id, + resume=resume, + jsonl_path=jsonl_path, + start_offset=start_offset, + ) + captured.append(session) + return session + + return _real() + + backend = ClaudeCodeBackend( + BackendOptions(cwd=str(tmp_path), dangerously_skip_permissions=True), + _session_factory=capturing_factory, + ) + backend_box["b"] = backend + + saw_event = asyncio.Event() + events: list[Any] = [] + + long_prompt = ( + "Please count slowly from 1 to 500, one number per line, in plain text. " + "Do not stop until you reach 500." + ) + + async def consumer() -> None: + async for event in backend.complete([{"role": "user", "content": long_prompt}]): + events.append(event) + saw_event.set() + + task = asyncio.create_task(consumer()) + try: + # Wait until we have at least one event so we know the turn is in + # flight on a live PTY. 30s is comfortably above the typical + # spawn + first-record latency (~3-5s for cold claude startup). + await asyncio.wait_for(saw_event.wait(), timeout=30.0) + + assert len(captured) == 1, ( + f"expected exactly one captured session at cancel time; got {len(captured)}" + ) + live = captured[0] + pid = live.pty.pid + assert pid is not None and pid > 0 + assert live.pty.is_alive() is True + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # SIGTERM ladder runs inside session.aclose() during cleanup, so by + # the time `await task` returns the PTY has been reaped. + assert live.pty.is_alive() is False, "PTY still alive after cancel cleanup" + assert backend.live_session_count == 0, ( + "cancelled session must not be re-stashed in the live pool" + ) + + # Belt-and-suspenders: confirm the OS no longer has the pid. + # `os.kill(pid, 0)` raises ProcessLookupError when the process is gone; + # any other state (zombie not yet reaped, still alive) raises something + # else or returns successfully. We accept both ProcessLookupError and + # the kernel reporting the pid is gone. + try: + os.kill(pid, 0) + # If we got here, the pid is still claimable. With pty.close(force=True) + # in _reap that shouldn't happen, but on macOS the reap might race + # very briefly — give it one more beat. + await asyncio.sleep(0.2) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + except ProcessLookupError: + pass # expected: process is gone + + # Lock released — a fresh call must still work end-to-end. + followup_events: list[Any] = [] + async for ev in backend.complete([{"role": "user", "content": "say hi"}]): + followup_events.append(ev) + assert isinstance(followup_events[-1], ResultMessage), ( + "follow-up turn failed; backend may have leaked state after cancel" + ) + finally: + # Defensive: if anything above failed, make sure we don't leave a + # zombie claude around for the next test run. + if not task.done(): + task.cancel() + with contextlib.suppress(BaseException): + await task + for s in captured: + if s.pty.is_alive(): + with contextlib.suppress(BaseException): + s.pty._pty.kill(_signal.SIGKILL) # type: ignore[union-attr] + await backend.aclose() diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..f216764 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,125 @@ +"""Unit tests for the Stage 10 error hierarchy + PTY-output classifier.""" + +from __future__ import annotations + +import pytest + +from claude_code_api import ( + AuthError, + BackendError, + CLINotFoundError, + MessageParseError, + ProcessError, + RateLimitError, + SessionError, + classify_pty_failure, +) + + +def test_hierarchy_roots_under_backend_error() -> None: + # Every backend-emitted exception must descend from BackendError so a + # gateway can install a single catch-all handler. + for cls in ( + AuthError, + MessageParseError, + ProcessError, + RateLimitError, + SessionError, + ): + assert issubclass(cls, BackendError) + assert issubclass(CLINotFoundError, ProcessError) + + +def test_process_error_carries_exit_code_and_stderr_in_message() -> None: + exc = ProcessError("boom", exit_code=7, stderr="line1\nline2") + assert exc.exit_code == 7 + assert exc.stderr == "line1\nline2" + rendered = str(exc) + assert "boom" in rendered + assert "exit code: 7" in rendered + assert "line1" in rendered # included in the tail + + +def test_process_error_tail_caps_huge_stderr() -> None: + # A 5KB blob should not embed wholesale in the message. + blob = "x" * 5000 + exc = ProcessError("oops", stderr=blob) + rendered = str(exc) + # Tail is capped to 2000 chars in the formatter. + assert rendered.count("x") <= 2000 + 10 # +slack for any literal 'x' in prefix + + +def test_cli_not_found_appends_executable() -> None: + exc = CLINotFoundError(executable="/usr/local/bin/claude") + assert "/usr/local/bin/claude" in str(exc) + assert exc.executable == "/usr/local/bin/claude" + # Default constructor is also valid. + bare = CLINotFoundError() + assert "not found" in str(bare).lower() + + +def test_classify_pty_failure_returns_none_when_no_marker() -> None: + assert classify_pty_failure(b"the model is thinking...") is None + assert classify_pty_failure("") is None + + +def test_classify_auth_markers() -> None: + assert classify_pty_failure(b"Failed to authenticate (status 401)") is AuthError + assert classify_pty_failure(b"API Error: 403 Forbidden") is AuthError + # claude-p's compact match handles "Please run /login" even when ANSI + # / spinner punctuation splits the words. + assert classify_pty_failure(b"Please run /login to continue.") is AuthError + assert ( + classify_pty_failure(b"\x1b[31mPlease\x1b[0m run /login") + is AuthError + ) + + +def test_classify_rate_limit_markers() -> None: + assert classify_pty_failure(b"You've hit your limit. Try again later.") is RateLimitError + assert classify_pty_failure(b"You have hit your limit.") is RateLimitError + # Bare form (TUI sometimes wraps the noun out). + assert classify_pty_failure(b"hit your limit") is RateLimitError + + +def test_classify_strips_ansi_before_matching() -> None: + # Common SGR sequences should not block the marker. + coloured = b"\x1b[1;31mYou've hit your limit\x1b[0m" + assert classify_pty_failure(coloured) is RateLimitError + + +def test_classify_accepts_str_or_bytes() -> None: + assert classify_pty_failure("Failed to authenticate") is AuthError + assert classify_pty_failure(b"Failed to authenticate") is AuthError + + +def test_auth_and_rate_limit_default_messages() -> None: + # Default messages are descriptive enough to surface to a gateway. + assert "auth" in str(AuthError()).lower() + assert "rate" in str(RateLimitError()).lower() or "limit" in str(RateLimitError()).lower() + + +def test_session_error_is_plain_backend_error() -> None: + # No special fields — just a typed marker. + exc = SessionError("never appeared") + assert isinstance(exc, BackendError) + assert "never appeared" in str(exc) + + +def test_message_parse_error_carries_data() -> None: + payload = {"oops": True} + exc = MessageParseError("bad shape", data=payload) + assert exc.data is payload + + +def test_session_error_is_not_a_timeout_error() -> None: + # We deliberately broke the TimeoutError lineage: gateways that used to + # catch TimeoutError must migrate to SessionError. Pin that. + assert not issubclass(SessionError, TimeoutError) + + +def test_raise_chain_smoke() -> None: + with pytest.raises(AuthError): + raise AuthError() + with pytest.raises(BackendError): + raise RateLimitError() diff --git a/tests/test_injection.py b/tests/test_injection.py new file mode 100644 index 0000000..28c1b6a --- /dev/null +++ b/tests/test_injection.py @@ -0,0 +1,193 @@ +"""Unit tests for `history_injection` helpers. + +Pure functions, no claude / no filesystem. The seed-JSONL shape is regression +tested against the same minimal contract that `probe_jsonl_injection.py` +proved out empirically (see FINDINGS § *Native JSONL injection works on +--resume*). +""" + +from __future__ import annotations + +import json + +import pytest + +from claude_code_api.injection import ( + build_concat_prompt, + build_seed_jsonl, + hash_history, +) + +# --- hash_history --------------------------------------------------------- + + +def test_hash_history_empty_is_stable() -> None: + assert hash_history([]) == hash_history([]) + + +def test_hash_history_distinguishes_content() -> None: + a = [{"role": "user", "content": "hi"}] + b = [{"role": "user", "content": "bye"}] + assert hash_history(a) != hash_history(b) + + +def test_hash_history_ignores_block_key_order() -> None: + """Two clients that serialize the same block in different key orders + must collide. Canonical-JSON serialization handles this.""" + a = [ + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "t1", "name": "echo", "input": {"x": 1}}], + } + ] + b = [ + { + "role": "assistant", + "content": [{"input": {"x": 1}, "name": "echo", "id": "t1", "type": "tool_use"}], + } + ] + assert hash_history(a) == hash_history(b) + + +def test_hash_history_rejects_unknown_role() -> None: + with pytest.raises(ValueError, match="role"): + hash_history([{"role": "system", "content": "x"}]) + + +def test_hash_history_text_blocks_collide_with_string_form() -> None: + """A bare string `content` and the equivalent single text block hash to + DIFFERENT values. They represent the same semantic content but appear + on the wire differently — the gateway must pick one form per role and + stay consistent. We don't try to paper over that here.""" + a = [{"role": "user", "content": "hello"}] + b = [{"role": "user", "content": [{"type": "text", "text": "hello"}]}] + assert hash_history(a) != hash_history(b) + + +# --- build_seed_jsonl ----------------------------------------------------- + + +def test_build_seed_jsonl_empty_is_empty_string() -> None: + assert build_seed_jsonl([], session_id="s", cwd="/tmp") == "" + + +def test_build_seed_jsonl_two_records_for_one_turn() -> None: + seed = build_seed_jsonl( + [ + {"role": "user", "content": "My name is Beaver."}, + {"role": "assistant", "content": "Got it."}, + ], + session_id="sid-1", + cwd="/work", + ) + lines = [json.loads(line) for line in seed.strip().splitlines()] + assert len(lines) == 2 + user_rec, asst_rec = lines + + assert user_rec["type"] == "user" + assert user_rec["sessionId"] == "sid-1" + assert user_rec["cwd"] == "/work" + assert user_rec["parentUuid"] is None + assert user_rec["message"] == {"role": "user", "content": "My name is Beaver."} + assert user_rec["isMeta"] is False + assert "uuid" in user_rec and "timestamp" in user_rec + + assert asst_rec["type"] == "assistant" + assert asst_rec["parentUuid"] == user_rec["uuid"] + assert asst_rec["message"]["role"] == "assistant" + assert asst_rec["message"]["content"] == [{"type": "text", "text": "Got it."}] + assert asst_rec["message"]["stop_reason"] == "end_turn" + assert asst_rec["sessionId"] == "sid-1" + + +def test_build_seed_jsonl_chains_parent_uuids_across_turns() -> None: + """The parentUuid graph must form a linear chain across turns — that's + how claude reconstructs conversation order on resume.""" + seed = build_seed_jsonl( + [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + {"role": "assistant", "content": "a2"}, + ], + session_id="s", + cwd="/tmp", + ) + recs = [json.loads(line) for line in seed.strip().splitlines()] + assert len(recs) == 4 + assert recs[0]["parentUuid"] is None + assert recs[1]["parentUuid"] == recs[0]["uuid"] + assert recs[2]["parentUuid"] == recs[1]["uuid"] + assert recs[3]["parentUuid"] == recs[2]["uuid"] + + +def test_build_seed_jsonl_passes_list_content_through_for_user() -> None: + """A user record with a tool_result block (the only list-form user + content claude itself writes) must round-trip verbatim.""" + seed = build_seed_jsonl( + [ + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "42"}, + ], + } + ], + session_id="s", + cwd="/tmp", + ) + rec = json.loads(seed.strip()) + assert rec["message"]["content"] == [ + {"type": "tool_result", "tool_use_id": "t1", "content": "42"}, + ] + + +def test_build_seed_jsonl_rejects_unknown_role() -> None: + with pytest.raises(ValueError, match="role"): + build_seed_jsonl([{"role": "system", "content": "x"}], session_id="s", cwd="/tmp") + + +# --- build_concat_prompt -------------------------------------------------- + + +def test_build_concat_prompt_empty_history_returns_just_last_user() -> None: + assert build_concat_prompt([], "hello") == "hello" + + +def test_build_concat_prompt_renders_alternating_history() -> None: + out = build_concat_prompt( + [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + {"role": "assistant", "content": "a2"}, + ], + "u3", + ) + assert "Previous conversation context:" in out + assert "[User]: u1" in out + assert "[Assistant]: a1" in out + assert "[User]: u2" in out + assert "[Assistant]: a2" in out + assert "Continue from here. New user message: u3" in out + # The new prompt must come after the history, not interleaved. + assert out.index("[Assistant]: a2") < out.index("Continue from here") + + +def test_build_concat_prompt_flattens_text_blocks_and_skips_tools() -> None: + """Content-as-list with text blocks gets flattened; tool blocks are + skipped (they don't round-trip through stdin in any useful form).""" + out = build_concat_prompt( + [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "hello"}, + {"type": "tool_use", "id": "t1", "name": "x", "input": {}}, + {"type": "text", "text": "world"}, + ], + }, + ], + "ping", + ) + assert "[Assistant]: hello world" in out diff --git a/tests/test_normalizer.py b/tests/test_normalizer.py new file mode 100644 index 0000000..26e2e4a --- /dev/null +++ b/tests/test_normalizer.py @@ -0,0 +1,421 @@ +"""Unit tests for Layer 3 (`event_normalizer.normalize`). + +All fixtures are hand-built dicts shaped like real records observed under +``~/.claude/projects/``; no `claude` is invoked. The normalizer is a pure +function so every test is a one-shot ``normalize(record) -> Event | None`` +assertion. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from claude_code_api import ( + AssistantMessage, + MessageParseError, + SystemMessage, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, + normalize, +) + +# --- envelope metadata shared by every record observed in the wild --------- + +_ENVELOPE: dict[str, Any] = { + "parentUuid": "parent-uuid", + "isSidechain": False, + "uuid": "rec-uuid", + "timestamp": "2026-05-16T20:17:27.664Z", + "userType": "external", + "entrypoint": "cli", + "cwd": "/some/cwd", + "sessionId": "sess-uuid", + "version": "2.1.143", + "gitBranch": "HEAD", +} + + +def _envelope(extra: dict[str, Any]) -> dict[str, Any]: + """Compose a record with the standard envelope plus the type-specific bits.""" + return {**_ENVELOPE, **extra} + + +# --- user records ---------------------------------------------------------- + + +def test_user_string_content() -> None: + rec = _envelope( + { + "type": "user", + "message": {"role": "user", "content": "hello there"}, + } + ) + event = normalize(rec) + assert isinstance(event, UserMessage) + assert event.content == "hello there" + assert event.uuid == "rec-uuid" + assert event.session_id == "sess-uuid" + assert event.parent_uuid == "parent-uuid" + + +def test_user_tool_result_content() -> None: + rec = _envelope( + { + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01", + "content": "stdout body", + "is_error": False, + } + ], + }, + } + ) + event = normalize(rec) + assert isinstance(event, UserMessage) + assert isinstance(event.content, list) + assert event.content == [ + ToolResultBlock( + tool_use_id="toolu_01", + content="stdout body", + is_error=False, + ) + ] + + +def test_user_meta_filtered_by_default() -> None: + rec = _envelope( + { + "type": "user", + "isMeta": True, + "message": {"role": "user", "content": "..."}, + } + ) + assert normalize(rec) is None + + +def test_user_meta_emitted_when_opt_in() -> None: + rec = _envelope( + { + "type": "user", + "isMeta": True, + "message": {"role": "user", "content": "x"}, + } + ) + event = normalize(rec, include_meta_user=True) + assert isinstance(event, UserMessage) + assert event.content == "x" + + +def test_user_missing_message_raises() -> None: + rec = _envelope({"type": "user"}) + with pytest.raises(MessageParseError, match="user record missing"): + normalize(rec) + + +def test_user_content_wrong_type_raises() -> None: + rec = _envelope({"type": "user", "message": {"content": 42}}) + with pytest.raises(MessageParseError, match="content must be str or list"): + normalize(rec) + + +# --- assistant records ----------------------------------------------------- + + +def test_assistant_text_only() -> None: + rec = _envelope( + { + "type": "assistant", + "message": { + "model": "claude-opus-4-7", + "id": "msg_01", + "role": "assistant", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 2}, + }, + } + ) + event = normalize(rec) + assert isinstance(event, AssistantMessage) + assert event.content == [TextBlock(text="hi")] + assert event.model == "claude-opus-4-7" + assert event.message_id == "msg_01" + assert event.stop_reason == "end_turn" + assert event.usage == {"input_tokens": 1, "output_tokens": 2} + + +def test_assistant_all_block_types() -> None: + rec = _envelope( + { + "type": "assistant", + "message": { + "model": "claude-opus-4-7", + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "...", "signature": "sig"}, + {"type": "text", "text": "calling tool"}, + { + "type": "tool_use", + "id": "toolu_01", + "name": "Bash", + "input": {"command": "ls"}, + }, + ], + "stop_reason": "tool_use", + }, + } + ) + event = normalize(rec) + assert isinstance(event, AssistantMessage) + assert event.content == [ + ThinkingBlock(thinking="...", signature="sig"), + TextBlock(text="calling tool"), + ToolUseBlock(id="toolu_01", name="Bash", input={"command": "ls"}), + ] + assert event.stop_reason == "tool_use" + + +def test_assistant_streaming_chunk_has_null_stop_reason() -> None: + # claude writes partial assistant records mid-turn with stop_reason=null; + # the normalizer surfaces the None so TurnManager can tell partial from + # terminal. + rec = _envelope( + { + "type": "assistant", + "message": { + "model": "claude-opus-4-7", + "role": "assistant", + "content": [{"type": "text", "text": "partial"}], + "stop_reason": None, + }, + } + ) + event = normalize(rec) + assert isinstance(event, AssistantMessage) + assert event.stop_reason is None + + +def test_assistant_missing_model_raises() -> None: + rec = _envelope( + { + "type": "assistant", + "message": {"role": "assistant", "content": []}, + } + ) + with pytest.raises(MessageParseError, match="assistant record missing"): + normalize(rec) + + +def test_assistant_content_not_list_raises() -> None: + rec = _envelope( + { + "type": "assistant", + "message": { + "model": "claude-opus-4-7", + "role": "assistant", + "content": "not a list", + }, + } + ) + with pytest.raises(MessageParseError, match="content must be a list"): + normalize(rec) + + +def test_assistant_unknown_block_type_raises() -> None: + rec = _envelope( + { + "type": "assistant", + "message": { + "model": "claude-opus-4-7", + "role": "assistant", + "content": [{"type": "image", "data": "..."}], + }, + } + ) + with pytest.raises(MessageParseError, match="unknown content block type"): + normalize(rec) + + +def test_assistant_tool_use_missing_id_raises() -> None: + rec = _envelope( + { + "type": "assistant", + "message": { + "model": "claude-opus-4-7", + "role": "assistant", + "content": [{"type": "tool_use", "name": "X", "input": {}}], + }, + } + ) + with pytest.raises(MessageParseError, match="tool_use block missing"): + normalize(rec) + + +# --- system records -------------------------------------------------------- + + +def test_system_turn_duration_surfaced() -> None: + rec = _envelope( + { + "type": "system", + "subtype": "turn_duration", + "durationMs": 1234, + "messageCount": 5, + "isMeta": False, + } + ) + event = normalize(rec) + assert isinstance(event, SystemMessage) + assert event.subtype == "turn_duration" + assert event.session_id == "sess-uuid" + # `data` mirrors the full raw record so callers can pull `durationMs` + # without re-parsing. + assert event.data["durationMs"] == 1234 + assert event.data["messageCount"] == 5 + + +def test_system_stop_hook_summary_filtered() -> None: + rec = _envelope( + { + "type": "system", + "subtype": "stop_hook_summary", + "hookCount": 0, + "hookInfos": [], + } + ) + assert normalize(rec) is None + + +def test_system_local_command_filtered() -> None: + rec = _envelope( + { + "type": "system", + "subtype": "local_command", + "content": "", + } + ) + assert normalize(rec) is None + + +def test_system_missing_subtype_raises() -> None: + rec = _envelope({"type": "system"}) + with pytest.raises(MessageParseError, match="system record missing 'subtype'"): + normalize(rec) + + +# --- filtered top-level types --------------------------------------------- + + +@pytest.mark.parametrize( + "record_type", + [ + "attachment", + "file-history-snapshot", + "last-prompt", + "ai-title", + "permission-mode", + "queue-operation", + ], +) +def test_bookkeeping_types_filtered(record_type: str) -> None: + rec = _envelope({"type": record_type}) + assert normalize(rec) is None + + +def test_unknown_type_silently_dropped() -> None: + # forward-compat: a brand-new top-level record type from a future claude + # version is dropped, not raised. + rec = _envelope({"type": "some-new-record-type"}) + assert normalize(rec) is None + + +# --- error path ------------------------------------------------------------ + + +def test_non_dict_record_raises() -> None: + with pytest.raises(MessageParseError, match="must be a dict"): + normalize("not a dict") # type: ignore[arg-type] + + +def test_record_missing_type_raises() -> None: + rec = _envelope({}) + with pytest.raises(MessageParseError, match="record missing 'type'"): + normalize(rec) + + +# --- regression fixtures from real session --------------------------------- + + +def test_real_user_string_record() -> None: + """Copy-paste of an actual user prompt record from a 2.1.143 session.""" + rec = { + "parentUuid": None, + "isSidechain": False, + "promptId": "364db1ee-f587-4096-bc6c-0dc4323512dc", + "type": "user", + "message": {"role": "user", "content": "What is my name?"}, + "uuid": "97968a26-6466-4410-84db-2077e65573e1", + "timestamp": "2026-05-16T20:17:27.664Z", + "userType": "external", + "entrypoint": "cli", + "cwd": "/Users/h/projects/playgrounds/claude-code-sdk", + "sessionId": "4df01eee-6026-4782-bdba-d67ab47a3e5b", + "version": "2.1.143", + "gitBranch": "HEAD", + } + event = normalize(rec) + assert isinstance(event, UserMessage) + assert event.content == "What is my name?" + assert event.parent_uuid is None + + +def test_real_assistant_tool_use_record() -> None: + """Copy-paste of a real ``stop_reason=tool_use`` assistant record.""" + rec = { + "parentUuid": "97968a26-6466-4410-84db-2077e65573e1", + "isSidechain": False, + "message": { + "model": "claude-opus-4-7", + "id": "msg_019Sy3eBbN24Y6YwgxuMvN7g", + "type": "message", + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "...", "signature": "sig"}, + { + "type": "tool_use", + "id": "toolu_01XCXcKt7TaDbAKscRPpvumi", + "name": "Bash", + "input": {"command": "ls"}, + }, + ], + "stop_reason": "tool_use", + "usage": { + "input_tokens": 6, + "cache_creation_input_tokens": 11211, + "cache_read_input_tokens": 17654, + "output_tokens": 172, + }, + }, + "requestId": "req_011Cb6s6f7fhCRgo2yhNZY9G", + "type": "assistant", + "uuid": "14e394aa-9faa-4448-8a6c-1365bf2acb8a", + "sessionId": "4df01eee-6026-4782-bdba-d67ab47a3e5b", + } + event = normalize(rec) + assert isinstance(event, AssistantMessage) + assert event.stop_reason == "tool_use" + assert event.usage is not None + assert event.usage["cache_read_input_tokens"] == 17654 + assert len(event.content) == 2 + assert isinstance(event.content[1], ToolUseBlock) + assert event.content[1].name == "Bash" diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..cfff17a --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,101 @@ +"""Unit tests for `jsonl_paths` — pure string transforms + light fs lookup.""" + +from __future__ import annotations + +import pytest + +from claude_code_api.paths import ( + claude_home, + encode_project_key, + find_jsonl_by_session_id, + projects_root, + resolve_jsonl_path, + session_dir, +) + +# ----- encode_project_key ----------------------------------------------------- + + +@pytest.mark.parametrize( + ("cwd", "expected"), + [ + # Observed in this repo: bare alnum + slashes + literal dashes. + ( + "/Users/h/projects/playgrounds/claude-code-sdk", + "-Users-h-projects-playgrounds-claude-code-sdk", + ), + # Observed: dot-prefixed dir produces doubled dash, dash-containing + # path segments survive unchanged. + ( + "/Users/h/.t3/worktrees/cars-system/t3code-9d8591ad", + "-Users-h--t3-worktrees-cars-system-t3code-9d8591ad", + ), + # Trailing slash collapses to a trailing dash — claude would not + # normally see this, but the encoder is deterministic. + ("/Users/h/", "-Users-h-"), + # Root. + ("/", "-"), + # Spaces, parentheses, other punct all become dashes. + ("/tmp/My Project (v2)", "-tmp-My-Project--v2-"), + ], +) +def test_encode_known_paths(cwd: str, expected: str) -> None: + assert encode_project_key(cwd) == expected + + +def test_encode_rejects_relative() -> None: + with pytest.raises(ValueError, match="absolute"): + encode_project_key("relative/path") + + +def test_encode_rejects_empty() -> None: + with pytest.raises(ValueError, match="empty"): + encode_project_key("") + + +# ----- resolve_jsonl_path / session_dir -------------------------------------- + + +def test_resolve_jsonl_path_under_fake_home(tmp_path): + sid = "deadbeef-0000-4000-8000-000000000001" + p = resolve_jsonl_path("/foo/bar", sid, home=tmp_path) + assert p == tmp_path / ".claude" / "projects" / "-foo-bar" / f"{sid}.jsonl" + + +def test_session_dir_matches_resolve_parent(tmp_path): + sid = "deadbeef-0000-4000-8000-000000000002" + assert resolve_jsonl_path("/a/b", sid, home=tmp_path).parent == session_dir( + "/a/b", home=tmp_path + ) + + +def test_resolve_rejects_empty_session_id(tmp_path): + with pytest.raises(ValueError, match="session_id"): + resolve_jsonl_path("/foo", "", home=tmp_path) + + +def test_claude_home_and_projects_root_honor_override(tmp_path): + assert claude_home(tmp_path) == tmp_path / ".claude" + assert projects_root(tmp_path) == tmp_path / ".claude" / "projects" + + +# ----- find_jsonl_by_session_id --------------------------------------------- + + +def test_find_returns_none_when_root_missing(tmp_path): + # No `.claude/projects` under tmp_path. + assert find_jsonl_by_session_id("nope", home=tmp_path) is None + + +def test_find_locates_existing_session(tmp_path): + sid = "abcdef00-1111-4000-8000-000000000000" + p = resolve_jsonl_path("/some/cwd", sid, home=tmp_path) + p.parent.mkdir(parents=True) + p.write_text("{}\n") + found = find_jsonl_by_session_id(sid, home=tmp_path) + assert found == p + + +def test_find_rejects_empty_session_id(tmp_path): + with pytest.raises(ValueError, match="session_id"): + find_jsonl_by_session_id("", home=tmp_path) diff --git a/tests/test_pty.py b/tests/test_pty.py new file mode 100644 index 0000000..97fb777 --- /dev/null +++ b/tests/test_pty.py @@ -0,0 +1,261 @@ +"""Unit + smoke tests for Layer 1 (`PtyClaudeProcess`). + +Unit tests exercise pure argv/env construction and don't require `claude`. +The smoke test spawns the real binary and is opt-in via env var because it +hits the user's OAuth state and the wider system. +""" + +from __future__ import annotations + +import asyncio +import os + +import pytest + +from claude_code_api import CLINotFoundError +from claude_code_api.pty import ( + PtyClaudeProcess, + PtyProcessOptions, + build_argv, + build_env, +) + +# --- argv construction ---------------------------------------------------- + + +def test_build_argv_minimal_uses_session_id_and_permission_mode() -> None: + opts = PtyProcessOptions(cwd="/tmp") + argv = build_argv(opts, session_id="abc-123") + + assert argv[0] == "claude" + # --session-id must come early so it can be observed in `ps` output even + # if later flags are mistyped/dropped. + assert argv[1:3] == ["--session-id", "abc-123"] + assert "--permission-mode" in argv + pm_index = argv.index("--permission-mode") + assert argv[pm_index + 1] == "bypassPermissions" + # Must never contain headless-only flags. + for forbidden in ("--print", "-p", "--output-format", "--input-format"): + assert forbidden not in argv + + +def test_build_argv_dangerously_skip_permissions_excludes_permission_mode() -> None: + opts = PtyProcessOptions(cwd="/tmp", dangerously_skip_permissions=True) + argv = build_argv(opts, session_id="s") + + assert "--dangerously-skip-permissions" in argv + assert "--permission-mode" not in argv + + +def test_build_argv_includes_optional_flags_when_set() -> None: + opts = PtyProcessOptions( + cwd="/tmp", + model="claude-opus-4-7", + system_prompt="be brief", + append_system_prompt="also be kind", + allowed_tools=("Read", "Glob"), + disallowed_tools=("Bash",), + mcp_config=("/tmp/a.json", "/tmp/b.json"), + add_dir=("/srv/x", "/srv/y"), + effort="high", + settings="/tmp/settings.json", + extra_args=("--brief",), + ) + argv = build_argv(opts, session_id="s") + + # Each flag should pair with its value. + def _pairs(flag: str) -> list[str]: + return [argv[i + 1] for i, v in enumerate(argv) if v == flag and i + 1 < len(argv)] + + assert _pairs("--model") == ["claude-opus-4-7"] + assert _pairs("--system-prompt") == ["be brief"] + assert _pairs("--append-system-prompt") == ["also be kind"] + # CSV form per claude CLI conventions. + assert _pairs("--allowedTools") == ["Read,Glob"] + assert _pairs("--disallowedTools") == ["Bash"] + assert _pairs("--mcp-config") == ["/tmp/a.json", "/tmp/b.json"] + assert _pairs("--effort") == ["high"] + assert _pairs("--settings") == ["/tmp/settings.json"] + # --add-dir is variadic in claude CLI: one flag, multiple values. + add_dir_at = argv.index("--add-dir") + assert argv[add_dir_at + 1 : add_dir_at + 3] == ["/srv/x", "/srv/y"] + # extra_args are passthrough at the end. + assert argv[-1] == "--brief" + + +def test_build_argv_omits_unset_optionals() -> None: + opts = PtyProcessOptions(cwd="/tmp") + argv = build_argv(opts, session_id="s") + for flag in ( + "--model", + "--system-prompt", + "--append-system-prompt", + "--allowedTools", + "--disallowedTools", + "--mcp-config", + "--add-dir", + "--effort", + "--settings", + ): + assert flag not in argv + + +def test_build_argv_resume_session_id_replaces_session_id_flag() -> None: + """Resume mode swaps `--session-id ` for `--resume `. + + claude rejects the two flags together unless `--fork-session` is also + passed (which would branch the session into a new JSONL). Higher layers + pick resume mode when they've seeded a JSONL by hand and need claude to + pick it up rather than create a new one. + """ + opts = PtyProcessOptions(cwd="/tmp", resume_session_id="resume-uuid") + argv = build_argv(opts, session_id="ignored-fresh-uuid") + + assert argv[1:3] == ["--resume", "resume-uuid"] + assert "--session-id" not in argv + + +def test_options_reject_session_id_with_resume_session_id() -> None: + with pytest.raises(ValueError, match="session_id"): + PtyProcessOptions(cwd="/tmp", session_id="a", resume_session_id="b") + + +def test_pty_process_reports_resume_session_id_as_session_id() -> None: + """When constructed in resume mode, the process advertises the resumed + session id (the id of the JSONL on disk) — not a fresh uuid. Higher + layers rely on `pty.session_id` to compute the JSONL path.""" + proc = PtyClaudeProcess(PtyProcessOptions(cwd="/tmp", resume_session_id="seeded-123")) + assert proc.session_id == "seeded-123" + assert "--resume" in proc.argv + assert "--session-id" not in proc.argv + + +def test_options_reject_invalid_permission_mode() -> None: + with pytest.raises(ValueError, match="permission_mode"): + PtyProcessOptions(cwd="/tmp", permission_mode="banana") + + +def test_options_reject_nonpositive_dimensions() -> None: + with pytest.raises(ValueError, match="dimensions"): + PtyProcessOptions(cwd="/tmp", dimensions=(0, 80)) + + +# --- env construction ----------------------------------------------------- + + +def test_build_env_strips_provider_vars_by_default() -> None: + base = { + "PATH": "/usr/bin", + "HOME": "/home/x", + "ANTHROPIC_API_KEY": "sk-xxx", + "ANTHROPIC_AUTH_TOKEN": "tok", + "ANTHROPIC_BASE_URL": "https://x.example", + } + env = build_env(PtyProcessOptions(cwd="/tmp"), base=base) + for name in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"): + assert name not in env + assert env["PATH"] == "/usr/bin" + assert env["HOME"] == "/home/x" + assert env["TERM"] == "xterm-256color" + assert env["NO_COLOR"] == "1" + + +def test_build_env_preserve_provider_env_keeps_keys() -> None: + base = {"ANTHROPIC_API_KEY": "sk-xxx", "PATH": "/usr/bin"} + opts = PtyProcessOptions(cwd="/tmp", preserve_provider_env=True) + env = build_env(opts, base=base) + assert env["ANTHROPIC_API_KEY"] == "sk-xxx" + + +def test_build_env_extra_env_overrides_base() -> None: + base = {"PATH": "/usr/bin", "TERM": "dumb"} + opts = PtyProcessOptions(cwd="/tmp", extra_env={"FOO": "bar", "TERM": "vt100"}) + env = build_env(opts, base=base) + assert env["FOO"] == "bar" + # Explicit override should win over the default TERM we set in build_env. + assert env["TERM"] == "vt100" + + +# --- construction-only PtyClaudeProcess sanity ---------------------------- + + +def test_session_id_is_autogenerated_when_omitted() -> None: + proc = PtyClaudeProcess(PtyProcessOptions(cwd="/tmp")) + # UUID4 is 36 chars including dashes. + assert len(proc.session_id) == 36 + assert proc.is_alive() is False + assert proc.pid is None + + +def test_session_id_is_passed_through_when_provided() -> None: + proc = PtyClaudeProcess(PtyProcessOptions(cwd="/tmp", session_id="custom-id")) + assert proc.session_id == "custom-id" + assert "--session-id" in proc.argv + assert proc.argv[proc.argv.index("--session-id") + 1] == "custom-id" + + +# --- error mapping (Stage 10) --------------------------------------------- + + +@pytest.mark.asyncio +async def test_start_raises_cli_not_found_when_executable_missing(tmp_path) -> None: + """`PtyClaudeProcess.start()` lifts ptyprocess's `FileNotFoundError` + (which fires from the pre-fork `which()` lookup) into our typed + `CLINotFoundError` so callers don't need to know about the underlying + library.""" + opts = PtyProcessOptions( + cwd=str(tmp_path), + executable="claude-binary-that-does-not-exist-xyz", + dangerously_skip_permissions=True, + ) + proc = PtyClaudeProcess(opts) + with pytest.raises(CLINotFoundError) as info: + await proc.start() + assert "claude-binary-that-does-not-exist-xyz" in str(info.value) + assert info.value.executable == "claude-binary-that-does-not-exist-xyz" + + +# --- smoke test (real claude) --------------------------------------------- + +_SMOKE_ENV = "RUN_CLAUDE_SMOKE" + + +@pytest.mark.skipif( + os.environ.get(_SMOKE_ENV) != "1", + reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test", +) +@pytest.mark.asyncio +async def test_smoke_start_write_terminate(tmp_path) -> None: + """End-to-end Layer 1 check against the installed `claude` binary. + + Spawns claude under a PTY, confirms it's alive, sends a no-op message + (which we don't expect a turn to complete in this test), then terminates + cleanly via SIGTERM. We only assert lifecycle invariants here — JSONL + parsing and turn semantics live in later layers. + """ + opts = PtyProcessOptions( + cwd=str(tmp_path), + dangerously_skip_permissions=True, + ) + proc = PtyClaudeProcess(opts) + await proc.start() + pid = proc.pid + try: + assert pid is not None and pid > 0 + # Give claude a moment to paint the TUI before we ask it to die. + # If it can't even stay alive for a beat, something is fundamentally + # wrong with the spawn (auth blocked, missing HOME, etc.). + await asyncio.sleep(0.5) + captured = proc.captured_output() + assert proc.is_alive(), ( + f"claude exited within 0.5s of spawn; captured {len(captured)} bytes:\n" + f"{captured[:1000]!r}" + ) + await proc.write("hello") + finally: + exit_status = await proc.terminate(grace=5.0) + assert proc.is_alive() is False + # Either an exit code or a signal — anything other than `None` is fine. + assert exit_status is not None, ( + f"terminate() returned None for pid={pid}; output:\n{proc.captured_output()[:1000]!r}" + ) diff --git a/tests/test_turn.py b/tests/test_turn.py new file mode 100644 index 0000000..eb2717d --- /dev/null +++ b/tests/test_turn.py @@ -0,0 +1,934 @@ +"""Unit + smoke tests for Layer 4 (`TurnManager`). + +Unit tests use a `FakePty` that, on `write()`, dumps a scripted list of JSONL +records into a real temp file. A real `JsonlWatcher` tails that file so the +manager's read/normalize/turn-end loop is exercised end-to-end without +launching `claude`. The smoke test at the bottom spawns the real binary +behind `RUN_CLAUDE_SMOKE=1` and also serves as the empirical probe for +Open Q #2 (PTY echo / buffering). +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path +from typing import Any + +import pytest + +from claude_code_api import ( + AssistantMessage, + AuthError, + ProcessError, + RateLimitError, + ResultMessage, + SessionError, + SystemMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) +from claude_code_api.paths import resolve_jsonl_path +from claude_code_api.watcher import JsonlWatcher +from claude_code_api.pty import PtyClaudeProcess, PtyProcessOptions +from claude_code_api.turn import TurnManager + +# --- fakes ----------------------------------------------------------------- + + +class FakePty: + """Stand-in for `PtyClaudeProcess` that flushes a scripted JSONL batch on write. + + The script is a list of records that get appended to `jsonl_path` (one + JSON object per line) as soon as the manager calls `write()`. This lets + a single synchronous setup drive the full turn loop — no async + coordination, no real `claude`. Multi-write scripts are supported: the + Nth `write()` flushes the Nth element of `scripts`. + + Stage 10 additions: `alive` and `output` knobs let tests simulate + sub-process death and error chrome captured from the PTY drain buffer, + which `TurnManager` consults when classifying failures. + """ + + def __init__( + self, + tmp_path: Path, + *, + session_id: str = "fake-session-0001", + scripts: list[list[dict[str, Any]]] | None = None, + alive: bool = True, + output: bytes = b"", + ) -> None: + self.cwd = str(tmp_path) + self.session_id = session_id + self._jsonl = tmp_path / f"{session_id}.jsonl" + self._scripts = scripts if scripts is not None else [] + self._write_count = 0 + self.writes: list[str] = [] + self.started = False + self.closed = False + self._alive = alive + self._output = output + + async def start(self) -> None: + self.started = True + + async def write(self, text: str, *, newline: bool = True) -> int: + self.writes.append(text) + if self._write_count < len(self._scripts): + records = self._scripts[self._write_count] + with self._jsonl.open("a", encoding="utf-8") as f: + for rec in records: + f.write(json.dumps(rec) + "\n") + self._write_count += 1 + return len(text) + + async def aclose(self) -> None: + self.closed = True + + # --- Stage 10 surface ---------------------------------------------- + def is_alive(self) -> bool: + return self._alive + + def captured_output(self) -> bytes: + return self._output + + def set_alive(self, alive: bool) -> None: + self._alive = alive + + def set_output(self, output: bytes) -> None: + self._output = output + + +def _user_rec(text: str) -> dict[str, Any]: + return { + "type": "user", + "uuid": f"u-{text[:8]}", + "sessionId": "fake-session-0001", + "parentUuid": None, + "message": {"role": "user", "content": text}, + } + + +def _assistant_rec( + text: str, + *, + stop_reason: str | None = "end_turn", + usage: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "type": "assistant", + "uuid": f"a-{text[:8]}", + "sessionId": "fake-session-0001", + "parentUuid": None, + "message": { + "id": "msg_x", + "role": "assistant", + "model": "claude-test", + "content": [{"type": "text", "text": text}], + "stop_reason": stop_reason, + "usage": usage or {"input_tokens": 1, "output_tokens": 1}, + }, + } + + +def _tool_use_assistant_rec(name: str, tool_id: str) -> dict[str, Any]: + return { + "type": "assistant", + "uuid": f"a-tu-{tool_id}", + "sessionId": "fake-session-0001", + "parentUuid": None, + "message": { + "id": "msg_y", + "role": "assistant", + "model": "claude-test", + "content": [{"type": "tool_use", "id": tool_id, "name": name, "input": {}}], + "stop_reason": "tool_use", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + } + + +def _tool_result_user_rec(tool_id: str, content: str) -> dict[str, Any]: + return { + "type": "user", + "uuid": f"u-tr-{tool_id}", + "sessionId": "fake-session-0001", + "parentUuid": None, + "message": { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": tool_id, "content": content}], + }, + } + + +def _turn_duration_rec(duration_ms: int = 1234) -> dict[str, Any]: + return { + "type": "system", + "subtype": "turn_duration", + "uuid": "sys-td", + "sessionId": "fake-session-0001", + "durationMs": duration_ms, + } + + +def _make_manager( + fake: FakePty, + *, + wait_for_turn_duration: bool = False, + startup_delay: float = 0.0, + turn_duration_timeout: float | None = 1.0, + on_parse_error: Any = None, +) -> TurnManager: + """Build a TurnManager wired to a real JsonlWatcher on the fake's path.""" + watcher = JsonlWatcher( + Path(fake.cwd) / f"{fake.session_id}.jsonl", + poll_interval=0.01, + ) + return TurnManager( + fake, # type: ignore[arg-type] + watcher, + wait_for_turn_duration=wait_for_turn_duration, + startup_delay=startup_delay, + turn_duration_timeout=turn_duration_timeout, + on_parse_error=on_parse_error, + ) + + +# --- construction validation ---------------------------------------------- + + +def test_init_rejects_negative_file_wait_timeout(tmp_path: Path) -> None: + fake = FakePty(tmp_path) + watcher = JsonlWatcher(tmp_path / "x.jsonl") + with pytest.raises(ValueError, match="file_wait_timeout"): + TurnManager(fake, watcher, file_wait_timeout=-1) # type: ignore[arg-type] + + +def test_init_rejects_negative_startup_delay(tmp_path: Path) -> None: + fake = FakePty(tmp_path) + watcher = JsonlWatcher(tmp_path / "x.jsonl") + with pytest.raises(ValueError, match="startup_delay"): + TurnManager(fake, watcher, startup_delay=-0.5) # type: ignore[arg-type] + + +def test_init_rejects_negative_turn_duration_timeout(tmp_path: Path) -> None: + fake = FakePty(tmp_path) + watcher = JsonlWatcher(tmp_path / "x.jsonl") + with pytest.raises(ValueError, match="turn_duration_timeout"): + TurnManager(fake, watcher, turn_duration_timeout=-1) # type: ignore[arg-type] + + +# --- lifecycle guards ----------------------------------------------------- + + +@pytest.mark.asyncio +async def test_send_before_start_raises(tmp_path: Path) -> None: + fake = FakePty(tmp_path) + tm = _make_manager(fake) + with pytest.raises(RuntimeError, match="before start"): + async for _ in tm.send_user_message("hi"): + pass + + +@pytest.mark.asyncio +async def test_start_is_idempotent(tmp_path: Path) -> None: + fake = FakePty(tmp_path) + tm = _make_manager(fake) + await tm.start() + await tm.start() + # FakePty.start() flips `started` either way; we just need no exception + # and a stable state machine. + assert fake.started is True + + +# --- happy path: one turn, terminal end_turn ------------------------------- + + +@pytest.mark.asyncio +async def test_basic_turn_yields_user_assistant_then_result(tmp_path: Path) -> None: + fake = FakePty( + tmp_path, + scripts=[ + [ + _user_rec("say hi"), + _assistant_rec("hi!", stop_reason="end_turn"), + # turn_duration is in the script but with + # wait_for_turn_duration=False it gets queued behind our + # early return — we don't yield it. + _turn_duration_rec(), + ] + ], + ) + tm = _make_manager(fake) + await tm.start() + events: list[Any] = [] + async for event in tm.send_user_message("say hi"): + events.append(event) + await tm.aclose() + + assert fake.writes == ["say hi"] + assert isinstance(events[0], UserMessage) + assert isinstance(events[1], AssistantMessage) + assert events[1].stop_reason == "end_turn" + assert isinstance(events[1].content[0], TextBlock) + assert isinstance(events[-1], ResultMessage) + assert events[-1].stop_reason == "end_turn" + assert events[-1].num_turns == 1 + assert events[-1].session_id == fake.session_id + # No turn_duration → duration_ms falls back to 0 in the synthesized result. + assert events[-1].duration_ms == 0 + + +@pytest.mark.asyncio +async def test_wait_for_turn_duration_carries_duration_ms(tmp_path: Path) -> None: + fake = FakePty( + tmp_path, + scripts=[ + [ + _user_rec("ping"), + _assistant_rec("pong", stop_reason="end_turn"), + _turn_duration_rec(duration_ms=4242), + ] + ], + ) + tm = _make_manager(fake, wait_for_turn_duration=True) + await tm.start() + events = [e async for e in tm.send_user_message("ping")] + await tm.aclose() + + # We also want the system event itself to be visible in the stream. + assert any(isinstance(e, SystemMessage) and e.subtype == "turn_duration" for e in events) + result = events[-1] + assert isinstance(result, ResultMessage) + assert result.duration_ms == 4242 + + +# --- tool loop continues until next terminal ----------------------------- + + +@pytest.mark.asyncio +async def test_tool_use_stop_reason_does_not_close_turn(tmp_path: Path) -> None: + fake = FakePty( + tmp_path, + scripts=[ + [ + _user_rec("compute"), + _tool_use_assistant_rec("Bash", "tool_1"), + _tool_result_user_rec("tool_1", "42"), + _assistant_rec("the answer is 42", stop_reason="end_turn"), + ] + ], + ) + tm = _make_manager(fake) + await tm.start() + events = [e async for e in tm.send_user_message("compute")] + await tm.aclose() + + assistants = [e for e in events if isinstance(e, AssistantMessage)] + # Both assistant records made it through — the tool_use one did not + # short-circuit the loop. + assert len(assistants) == 2 + assert assistants[0].stop_reason == "tool_use" + assert assistants[1].stop_reason == "end_turn" + assert isinstance(events[-1], ResultMessage) + assert events[-1].stop_reason == "end_turn" + + +# --- error & misuse paths ------------------------------------------------- + + +@pytest.mark.asyncio +async def test_parse_error_callback_keeps_stream_alive(tmp_path: Path) -> None: + # A bogus record (missing `message`) sits between two valid ones. The + # callback should fire once and the stream should still terminate cleanly. + bad = {"type": "assistant", "uuid": "x", "sessionId": "fake-session-0001"} + fake = FakePty( + tmp_path, + scripts=[ + [ + _user_rec("hi"), + bad, + _assistant_rec("ok", stop_reason="end_turn"), + ] + ], + ) + errors: list[tuple[Exception, dict[str, Any]]] = [] + tm = _make_manager(fake, on_parse_error=lambda exc, rec: errors.append((exc, rec))) + await tm.start() + events = [e async for e in tm.send_user_message("hi")] + await tm.aclose() + + assert len(errors) == 1 + assert errors[0][1] is bad or errors[0][1] == bad + assert isinstance(events[-1], ResultMessage) + + +@pytest.mark.asyncio +async def test_double_send_raises_while_turn_in_progress(tmp_path: Path) -> None: + # Manager that will NEVER see a terminal assistant (no scripted records). + # Drive one __anext__ on the first generator so it enters the polling loop, + # then attempt a second concurrent send. + fake = FakePty(tmp_path, scripts=[[]]) + # Touch the file so the file-wait doesn't block forever. + (tmp_path / f"{fake.session_id}.jsonl").touch() + tm = _make_manager(fake) + await tm.start() + + gen1 = tm.send_user_message("first") + # Spin up the generator: schedule one read pass. + task = asyncio.create_task(gen1.__anext__()) + await asyncio.sleep(0.05) # let _iter_turn flip turn_in_progress + + with pytest.raises(RuntimeError, match="turn is in progress"): + async for _ in tm.send_user_message("second"): + pass + + task.cancel() + with pytest.raises((asyncio.CancelledError, StopAsyncIteration)): + await task + await tm.aclose() + + +@pytest.mark.asyncio +async def test_aclose_terminates_owned_pty(tmp_path: Path) -> None: + fake = FakePty(tmp_path) + tm = _make_manager(fake) + await tm.start() + await tm.aclose() + assert fake.closed is True + + +@pytest.mark.asyncio +async def test_aclose_skips_pty_when_not_owned(tmp_path: Path) -> None: + fake = FakePty(tmp_path) + watcher = JsonlWatcher(tmp_path / f"{fake.session_id}.jsonl", poll_interval=0.01) + tm = TurnManager(fake, watcher, owns_pty=False, startup_delay=0.0) # type: ignore[arg-type] + await tm.start() + await tm.aclose() + assert fake.closed is False + + +# --- Stage 10: error mapping --------------------------------------------- + + +@pytest.mark.asyncio +async def test_session_error_raised_when_jsonl_never_appears(tmp_path: Path) -> None: + """No script → FakePty.write() doesn't create the JSONL → the + file-wait timeout fires → TurnManager raises SessionError (not the + raw asyncio.TimeoutError).""" + fake = FakePty(tmp_path, scripts=[]) # write() is a no-op for JSONL + watcher = JsonlWatcher( + tmp_path / f"{fake.session_id}.jsonl", + poll_interval=0.01, + ) + tm = TurnManager( + fake, # type: ignore[arg-type] + watcher, + startup_delay=0.0, + file_wait_timeout=0.05, # fire fast + ) + await tm.start() + with pytest.raises(SessionError): + async for _ in tm.send_user_message("hi"): + pass + await tm.aclose() + + +@pytest.mark.asyncio +async def test_auth_marker_in_pty_output_raises_auth_error(tmp_path: Path) -> None: + """When the JSONL never appears AND captured PTY output carries an + auth-block marker, the classifier promotes the failure to AuthError + (instead of the generic SessionError).""" + fake = FakePty( + tmp_path, + scripts=[], + output=b"Failed to authenticate. Please run /login.\r\n", + ) + watcher = JsonlWatcher( + tmp_path / f"{fake.session_id}.jsonl", + poll_interval=0.01, + ) + tm = TurnManager( + fake, # type: ignore[arg-type] + watcher, + startup_delay=0.0, + file_wait_timeout=0.05, + ) + await tm.start() + with pytest.raises(AuthError): + async for _ in tm.send_user_message("hi"): + pass + await tm.aclose() + + +@pytest.mark.asyncio +async def test_rate_limit_marker_promotes_session_error_to_rate_limit( + tmp_path: Path, +) -> None: + """Same path as the auth case but with a rate-limit marker.""" + fake = FakePty( + tmp_path, + scripts=[], + output=b"\x1b[31mYou've hit your limit\x1b[0m. Try again at 9pm.", + ) + watcher = JsonlWatcher( + tmp_path / f"{fake.session_id}.jsonl", + poll_interval=0.01, + ) + tm = TurnManager( + fake, # type: ignore[arg-type] + watcher, + startup_delay=0.0, + file_wait_timeout=0.05, + ) + await tm.start() + with pytest.raises(RateLimitError): + async for _ in tm.send_user_message("hi"): + pass + await tm.aclose() + + +@pytest.mark.asyncio +async def test_process_death_mid_poll_raises_process_error(tmp_path: Path) -> None: + """The JSONL appears (so we leave the wait-for-file phase) but no + terminal assistant ever arrives AND the PTY reports dead. Detection + fires from inside the poll loop, with the captured output included in + the exception so a gateway can log what claude wrote before exiting. + """ + fake = FakePty( + tmp_path, + scripts=[[_user_rec("hi")]], # only the user record — no assistant + output=b"some claude chrome before death\r\n", + ) + watcher = JsonlWatcher( + tmp_path / f"{fake.session_id}.jsonl", + poll_interval=0.01, + ) + tm = TurnManager( + fake, # type: ignore[arg-type] + watcher, + startup_delay=0.0, + file_wait_timeout=2.0, + ) + await tm.start() + + async def consumer() -> list[Any]: + events: list[Any] = [] + async for ev in tm.send_user_message("hi"): + events.append(ev) + # Once we've seen the user record, declare the PTY dead so the + # next polling pass enters the failure branch. + if isinstance(ev, UserMessage): + fake.set_alive(False) + return events + + with pytest.raises(ProcessError) as info: + await consumer() + assert "exited before a terminal" in str(info.value) + assert info.value.stderr is not None + assert "claude chrome before death" in info.value.stderr + await tm.aclose() + + +@pytest.mark.asyncio +async def test_process_death_with_rate_limit_marker_raises_rate_limit( + tmp_path: Path, +) -> None: + """Process-death classifier defers to the PTY marker: if the buffer + carries a rate-limit notice, raise the typed marker, not the generic + ProcessError.""" + fake = FakePty( + tmp_path, + scripts=[[_user_rec("hi")]], + output=b"You've hit your limit. Cooling off.", + ) + watcher = JsonlWatcher( + tmp_path / f"{fake.session_id}.jsonl", + poll_interval=0.01, + ) + tm = TurnManager( + fake, # type: ignore[arg-type] + watcher, + startup_delay=0.0, + file_wait_timeout=2.0, + ) + await tm.start() + + async def consumer() -> None: + async for ev in tm.send_user_message("hi"): + if isinstance(ev, UserMessage): + fake.set_alive(False) + + with pytest.raises(RateLimitError): + await consumer() + await tm.aclose() + + +# --- multi-turn (Stage 6) ------------------------------------------------- + + +@pytest.mark.asyncio +async def test_two_consecutive_turns_each_yield_only_fresh_records(tmp_path: Path) -> None: + """Stage 6 core: a second `send_user_message()` on the same manager sees + only the records appended after the first turn ended. + + The watcher is reused across turns and tracks the byte offset internally + (see PROGRESS.md decision log: "TurnManager does NOT own + JsonlWatcher.offset"). This test pins that contract. + """ + fake = FakePty( + tmp_path, + scripts=[ + [ + _user_rec("Q1"), + _assistant_rec("A1", stop_reason="end_turn"), + ], + [ + _user_rec("Q2"), + _assistant_rec("A2", stop_reason="end_turn"), + ], + ], + ) + tm = _make_manager(fake) + await tm.start() + + turn1 = [e async for e in tm.send_user_message("Q1")] + turn2 = [e async for e in tm.send_user_message("Q2")] + await tm.aclose() + + assert fake.writes == ["Q1", "Q2"] + + # Turn 1: user("Q1"), assistant("A1"), result + assert [type(e).__name__ for e in turn1] == [ + "UserMessage", + "AssistantMessage", + "ResultMessage", + ] + assert turn1[0].content == "Q1" + assert isinstance(turn1[1], AssistantMessage) + assert isinstance(turn1[1].content[0], TextBlock) + assert turn1[1].content[0].text == "A1" + assert isinstance(turn1[-1], ResultMessage) + assert turn1[-1].num_turns == 1 + + # Turn 2 must NOT leak any of turn 1's records back to the caller. + assert [type(e).__name__ for e in turn2] == [ + "UserMessage", + "AssistantMessage", + "ResultMessage", + ] + assert turn2[0].content == "Q2" + assert isinstance(turn2[1], AssistantMessage) + assert isinstance(turn2[1].content[0], TextBlock) + assert turn2[1].content[0].text == "A2" + + # Turn-count bookkeeping increments across turns; session_id is stable. + assert isinstance(turn2[-1], ResultMessage) + assert turn2[-1].num_turns == 2 + assert turn2[-1].session_id == turn1[-1].session_id == fake.session_id + assert tm.turn_count == 2 + + +@pytest.mark.asyncio +async def test_multi_turn_with_wait_for_turn_duration_carries_each_duration( + tmp_path: Path, +) -> None: + """When `wait_for_turn_duration=True`, each turn's synthesized result + carries its own duration. The watcher offset advances past the + intervening turn_duration heartbeat so turn 2 starts clean. + """ + fake = FakePty( + tmp_path, + scripts=[ + [ + _user_rec("ping1"), + _assistant_rec("pong1", stop_reason="end_turn"), + _turn_duration_rec(duration_ms=111), + ], + [ + _user_rec("ping2"), + _assistant_rec("pong2", stop_reason="end_turn"), + _turn_duration_rec(duration_ms=222), + ], + ], + ) + tm = _make_manager(fake, wait_for_turn_duration=True) + await tm.start() + + turn1 = [e async for e in tm.send_user_message("ping1")] + turn2 = [e async for e in tm.send_user_message("ping2")] + await tm.aclose() + + assert isinstance(turn1[-1], ResultMessage) + assert turn1[-1].duration_ms == 111 + assert turn1[-1].num_turns == 1 + + assert isinstance(turn2[-1], ResultMessage) + assert turn2[-1].duration_ms == 222 + assert turn2[-1].num_turns == 2 + + +# --- smoke test (real claude) --------------------------------------------- + +_SMOKE_ENV = "RUN_CLAUDE_SMOKE" + + +@pytest.mark.skipif( + os.environ.get(_SMOKE_ENV) != "1", + reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test", +) +@pytest.mark.asyncio +async def test_smoke_send_hi(tmp_path: Path) -> None: + """Smoke 1: end-to-end one-turn against real claude. + + Confirms: PTY spawn, JSONL discovery, watcher tail, normalizer mapping, + turn-end detection, and ResultMessage synthesis all line up. Also + doubles as the empirical probe for Open Q #2 — if claude doesn't pick up + our prompt after `pty.write("say hi\\r")`, the JSONL never grows and the + file-wait timeout fires; that failure mode tells us the carriage-return + + 1s startup delay is not enough and we need a different submit + mechanism. + """ + opts = PtyProcessOptions( + cwd=str(tmp_path), + dangerously_skip_permissions=True, + ) + pty = PtyClaudeProcess(opts) + jsonl_path = resolve_jsonl_path(pty.cwd, pty.session_id) + watcher = JsonlWatcher(jsonl_path) + + tm = TurnManager(pty, watcher) + try: + await tm.start() + events: list[Any] = [] + async for event in tm.send_user_message("say hi"): + events.append(event) + finally: + await tm.aclose() + + assistants = [e for e in events if isinstance(e, AssistantMessage)] + assert assistants, ( + f"no AssistantMessage in stream; got {[type(e).__name__ for e in events]}" + ) + terminal = next( + ( + a + for a in assistants + if a.stop_reason in {"end_turn", "max_tokens", "stop_sequence", "refusal"} + ), + None, + ) + assert terminal is not None, ( + f"no terminal stop_reason; got {[a.stop_reason for a in assistants]}" + ) + assert any(isinstance(b, TextBlock) for b in terminal.content) + assert isinstance(events[-1], ResultMessage) + assert events[-1].stop_reason == terminal.stop_reason + assert events[-1].session_id == pty.session_id + + +@pytest.mark.skipif( + os.environ.get(_SMOKE_ENV) != "1", + reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test", +) +@pytest.mark.asyncio +async def test_smoke_multi_turn_context_persists(tmp_path: Path) -> None: + """Smoke 2 (Stage 6): two turns on one TurnManager, the second must see + the first's context. + + Turn 1 plants a memorable token via the user message; turn 2 asks for it + back. If the same `--session-id` PTY truly accumulates context (as the + JSONL design implies), the second assistant text contains the token. If + instead each turn ran isolated, the second reply would not know it. + + The token is a low-entropy proper noun ("Beaver" — same one we used in + the JSONL injection probe) chosen to be unlikely-but-not-impossible to + appear spontaneously, so a false positive remains very unlikely while + keeping the prompt natural. + """ + opts = PtyProcessOptions( + cwd=str(tmp_path), + dangerously_skip_permissions=True, + ) + pty = PtyClaudeProcess(opts) + jsonl_path = resolve_jsonl_path(pty.cwd, pty.session_id) + watcher = JsonlWatcher(jsonl_path) + + tm = TurnManager(pty, watcher) + turn1_events: list[Any] = [] + turn2_events: list[Any] = [] + try: + await tm.start() + async for event in tm.send_user_message( + "Please remember: my name is Beaver. Reply with just 'ok'." + ): + turn1_events.append(event) + async for event in tm.send_user_message( + "What is my name? Answer with the single word only." + ): + turn2_events.append(event) + finally: + await tm.aclose() + + # Both turns yielded a synthesized result; num_turns increments. + assert isinstance(turn1_events[-1], ResultMessage) + assert isinstance(turn2_events[-1], ResultMessage) + assert turn1_events[-1].num_turns == 1 + assert turn2_events[-1].num_turns == 2 + assert turn1_events[-1].session_id == turn2_events[-1].session_id == pty.session_id + assert tm.turn_count == 2 + + # Second turn's terminal assistant must reference the planted token. + turn2_assistants = [e for e in turn2_events if isinstance(e, AssistantMessage)] + terminal2 = next( + ( + a + for a in turn2_assistants + if a.stop_reason in {"end_turn", "max_tokens", "stop_sequence", "refusal"} + ), + None, + ) + assert terminal2 is not None, ( + f"no terminal stop_reason in turn 2; got {[a.stop_reason for a in turn2_assistants]}" + ) + text2 = " ".join(b.text for b in terminal2.content if isinstance(b, TextBlock)) + assert "beaver" in text2.lower(), ( + f"turn 2 did not inherit context from turn 1; reply was: {text2!r}" + ) + + +# --- Stage 7: tool calls via external MCP server ------------------------- + + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_ECHO_MCP_SCRIPT = _REPO_ROOT / "scripts" / "echo_mcp_server.py" + + +@pytest.mark.skipif( + os.environ.get(_SMOKE_ENV) != "1", + reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test", +) +@pytest.mark.asyncio +async def test_smoke_tool_call_via_mcp(tmp_path: Path) -> None: + """Smoke 3 (Stage 7): real claude routes a tool call through an external + stdio MCP server, and the resulting `tool_use` + `tool_result` records + surface as typed events. + + Setup: + - `scripts/echo_mcp_server.py` is a zero-dep stdio MCP server with one + tool, `echo`, that returns its `text` argument verbatim. + - We point claude at it via a temp `--mcp-config` JSON file (one + server named "echo"). `--strict-mcp-config` keeps the user's + ambient `.mcp.json` from leaking in and changing the tool surface. + + Assertions: + - At least one `AssistantMessage.content` carries a `ToolUseBlock` + whose name references the echo tool (claude exposes external MCP + tools as `mcp____`, here `mcp__echo__echo`). + - The follow-up `UserMessage` carries a `ToolResultBlock` whose + content includes the marker token we asked the tool to echo — + the only place that token can come from is the MCP server, so + seeing it round-tripped proves the full path worked. + - A terminal assistant closes the turn and the synthesized + `ResultMessage` reflects its stop_reason. + """ + assert _ECHO_MCP_SCRIPT.exists(), f"missing echo MCP server at {_ECHO_MCP_SCRIPT}" + + marker = "banana42xyz" # low-collision sentinel; must appear in tool_result + + mcp_config_path = tmp_path / "mcp_config.json" + mcp_config_path.write_text( + json.dumps( + { + "mcpServers": { + "echo": { + "command": sys.executable, + "args": [str(_ECHO_MCP_SCRIPT)], + }, + }, + } + ) + ) + + opts = PtyProcessOptions( + cwd=str(tmp_path), + dangerously_skip_permissions=True, + mcp_config=(str(mcp_config_path),), + ) + pty = PtyClaudeProcess(opts) + jsonl_path = resolve_jsonl_path(pty.cwd, pty.session_id) + watcher = JsonlWatcher(jsonl_path) + + # External MCP servers spawn during claude's startup, so the input box + # mounts a bit later than for a bare session. The 60s file-wait still + # leaves headroom even on a slow first MCP handshake. + tm = TurnManager(pty, watcher, file_wait_timeout=60.0) + + prompt = f"Call mcp__echo__echo with text={marker!r}, then reply 'done'." + + events: list[Any] = [] + try: + await tm.start() + async for event in tm.send_user_message(prompt): + events.append(event) + finally: + await tm.aclose() + + # --- assertions --- + tool_uses: list[ToolUseBlock] = [] + for ev in events: + if isinstance(ev, AssistantMessage): + tool_uses.extend(b for b in ev.content if isinstance(b, ToolUseBlock)) + assert tool_uses, ( + "no ToolUseBlock in any assistant message; got " + f"{[type(e).__name__ for e in events]}" + ) + echo_uses = [t for t in tool_uses if "echo" in t.name.lower()] + assert echo_uses, ( + f"no tool_use referenced the echo tool; saw names {[t.name for t in tool_uses]}" + ) + + # The marker text only exists on the MCP server side, so finding it in a + # tool_result block proves the round-trip actually completed. + tool_results: list[ToolResultBlock] = [] + for ev in events: + if isinstance(ev, UserMessage) and isinstance(ev.content, list): + tool_results.extend(b for b in ev.content if isinstance(b, ToolResultBlock)) + assert tool_results, "no ToolResultBlock in any user message after the tool call" + + def _result_text(block: ToolResultBlock) -> str: + if isinstance(block.content, str): + return block.content + if isinstance(block.content, list): + chunks: list[str] = [] + for part in block.content: + if isinstance(part, dict) and isinstance(part.get("text"), str): + chunks.append(part["text"]) + return " ".join(chunks) + return "" + + assert any(marker in _result_text(b) for b in tool_results), ( + f"marker {marker!r} did not appear in any tool_result; got " + f"{[_result_text(b) for b in tool_results]}" + ) + + terminal_assistant = next( + ( + ev + for ev in events + if isinstance(ev, AssistantMessage) + and ev.stop_reason in {"end_turn", "max_tokens", "stop_sequence", "refusal"} + ), + None, + ) + assert terminal_assistant is not None, ( + "no terminal assistant after tool round-trip; got stop_reasons " + f"{[e.stop_reason for e in events if isinstance(e, AssistantMessage)]}" + ) + assert isinstance(events[-1], ResultMessage) + assert events[-1].stop_reason == terminal_assistant.stop_reason diff --git a/tests/test_watcher.py b/tests/test_watcher.py new file mode 100644 index 0000000..ab8c1b1 --- /dev/null +++ b/tests/test_watcher.py @@ -0,0 +1,364 @@ +"""Unit tests for Layer 2 (`JsonlWatcher`). + +All tests use temp files; no `claude` involved. The watcher is exercised both +in its single-pass mode (`read_once`) and in its long-running mode (`tail`). +For `tail`, a producer task appends to the file while a consumer pulls from +the async iterator; both run under one event loop with a short poll interval +so tests stay quick. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +from claude_code_api.watcher import JsonlWatcher + + +def _write_records(path: Path, records: list[dict]) -> None: + """Append JSONL records as a single text blob (with trailing newline).""" + blob = "".join(json.dumps(r) + "\n" for r in records) + with path.open("a", encoding="utf-8") as f: + f.write(blob) + + +# --- construction validation ------------------------------------------------ + + +def test_init_rejects_nonpositive_poll_interval(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="poll_interval"): + JsonlWatcher(tmp_path / "x.jsonl", poll_interval=0) + with pytest.raises(ValueError, match="poll_interval"): + JsonlWatcher(tmp_path / "x.jsonl", poll_interval=-1) + + +def test_init_rejects_negative_start_offset(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="start_offset"): + JsonlWatcher(tmp_path / "x.jsonl", start_offset=-1) + + +def test_init_rejects_nonpositive_read_chunk(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="read_chunk"): + JsonlWatcher(tmp_path / "x.jsonl", read_chunk=0) + + +def test_path_is_exposed(tmp_path: Path) -> None: + p = tmp_path / "x.jsonl" + w = JsonlWatcher(p) + assert w.path == p + assert w.offset == 0 + + +# --- read_once: synchronous behavior --------------------------------------- + + +@pytest.mark.asyncio +async def test_read_once_returns_empty_when_file_missing(tmp_path: Path) -> None: + w = JsonlWatcher(tmp_path / "missing.jsonl") + assert await w.read_once() == [] + # Offset must not advance when there's nothing to read. + assert w.offset == 0 + + +@pytest.mark.asyncio +async def test_read_once_returns_all_existing_records(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + records = [ + {"type": "user", "i": 0}, + {"type": "assistant", "i": 1}, + {"type": "system", "i": 2}, + ] + _write_records(p, records) + + w = JsonlWatcher(p) + got = await w.read_once() + assert got == records + # Offset should now be at EOF. + assert w.offset == p.stat().st_size + + +@pytest.mark.asyncio +async def test_read_once_is_incremental(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + _write_records(p, [{"i": 0}]) + w = JsonlWatcher(p) + assert await w.read_once() == [{"i": 0}] + + # Second pass with no new bytes: empty. + assert await w.read_once() == [] + + # Append more — only the new ones come out. + _write_records(p, [{"i": 1}, {"i": 2}]) + assert await w.read_once() == [{"i": 1}, {"i": 2}] + + +@pytest.mark.asyncio +async def test_read_once_buffers_partial_line(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + # Write a complete record + a partial record (no trailing newline). + rec1 = {"complete": True} + partial = '{"complete":' + with p.open("w", encoding="utf-8") as f: + f.write(json.dumps(rec1) + "\n") + f.write(partial) # no newline + + w = JsonlWatcher(p) + assert await w.read_once() == [rec1] + # Offset has consumed the partial bytes too — they're stashed internally. + assert w.offset == p.stat().st_size + + # Now finish the partial line. + with p.open("a", encoding="utf-8") as f: + f.write(" false}\n") + + assert await w.read_once() == [{"complete": False}] + + +@pytest.mark.asyncio +async def test_read_once_skips_blank_lines(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + # Mix in some blank lines — the watcher should ignore them rather than + # treat them as parse errors. + with p.open("w", encoding="utf-8") as f: + f.write("\n") + f.write(json.dumps({"i": 0}) + "\n") + f.write(" \n") + f.write(json.dumps({"i": 1}) + "\n") + f.write("\n") + + w = JsonlWatcher(p) + assert await w.read_once() == [{"i": 0}, {"i": 1}] + + +@pytest.mark.asyncio +async def test_read_once_invokes_parse_error_callback(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + with p.open("w", encoding="utf-8") as f: + f.write(json.dumps({"i": 0}) + "\n") + f.write("this is not json\n") + f.write(json.dumps({"i": 2}) + "\n") + + errors: list[tuple[bytes, Exception]] = [] + w = JsonlWatcher(p, on_parse_error=lambda line, exc: errors.append((line, exc))) + got = await w.read_once() + # Bad line skipped; valid ones returned. + assert got == [{"i": 0}, {"i": 2}] + assert len(errors) == 1 + bad_line, exc = errors[0] + assert bad_line == b"this is not json" + assert isinstance(exc, json.JSONDecodeError) + + +@pytest.mark.asyncio +async def test_read_once_drops_malformed_silently_without_callback(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + with p.open("w", encoding="utf-8") as f: + f.write("garbage\n") + f.write(json.dumps({"i": 1}) + "\n") + + w = JsonlWatcher(p) # no callback + assert await w.read_once() == [{"i": 1}] + + +@pytest.mark.asyncio +async def test_read_once_handles_chunk_boundary(tmp_path: Path) -> None: + """A record larger than `read_chunk` must still come out whole.""" + p = tmp_path / "s.jsonl" + big = {"payload": "x" * 8000, "i": 0} + small = {"i": 1} + _write_records(p, [big, small]) + w = JsonlWatcher(p, read_chunk=128) # force many chunks per record + assert await w.read_once() == [big, small] + + +@pytest.mark.asyncio +async def test_start_offset_skips_initial_content(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + _write_records(p, [{"i": 0}, {"i": 1}]) + initial_size = p.stat().st_size + + # Start a watcher pointed at EOF — it should see only future appends. + w = JsonlWatcher(p, start_offset=initial_size) + assert await w.read_once() == [] + + _write_records(p, [{"i": 2}]) + assert await w.read_once() == [{"i": 2}] + + +@pytest.mark.asyncio +async def test_read_once_resets_on_truncation(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + _write_records(p, [{"i": 0}, {"i": 1}]) + w = JsonlWatcher(p) + assert await w.read_once() == [{"i": 0}, {"i": 1}] + + # Truncate (or rotate) — write a brand-new shorter file. + p.write_text(json.dumps({"reset": True}) + "\n", encoding="utf-8") + assert await w.read_once() == [{"reset": True}] + assert w.offset == p.stat().st_size + + +# --- wait_for_file ---------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_wait_for_file_returns_immediately_if_exists(tmp_path: Path) -> None: + p = tmp_path / "exists.jsonl" + p.write_text("", encoding="utf-8") + w = JsonlWatcher(p, poll_interval=0.01) + # If this doesn't return promptly we'd hang — wrap in a tight timeout. + await asyncio.wait_for(w.wait_for_file(timeout=1.0), timeout=1.0) + + +@pytest.mark.asyncio +async def test_wait_for_file_picks_up_late_creation(tmp_path: Path) -> None: + p = tmp_path / "later.jsonl" + w = JsonlWatcher(p, poll_interval=0.01) + + async def create_later() -> None: + await asyncio.sleep(0.05) + p.write_text("", encoding="utf-8") + + creator = asyncio.create_task(create_later()) + try: + await asyncio.wait_for(w.wait_for_file(timeout=1.0), timeout=1.0) + finally: + await creator + + +@pytest.mark.asyncio +async def test_wait_for_file_times_out(tmp_path: Path) -> None: + p = tmp_path / "never.jsonl" + w = JsonlWatcher(p, poll_interval=0.01) + with pytest.raises(TimeoutError): + await w.wait_for_file(timeout=0.05) + + +@pytest.mark.asyncio +async def test_wait_for_file_rejects_negative_timeout(tmp_path: Path) -> None: + w = JsonlWatcher(tmp_path / "x.jsonl") + with pytest.raises(ValueError, match="timeout"): + await w.wait_for_file(timeout=-1) + + +# --- tail: long-running async iteration ------------------------------------ + + +@pytest.mark.asyncio +async def test_tail_yields_existing_records_first(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + _write_records(p, [{"i": 0}, {"i": 1}]) + w = JsonlWatcher(p, poll_interval=0.01) + + seen: list[dict] = [] + + async def consume() -> None: + async for rec in w.tail(): + seen.append(rec) + if len(seen) >= 2: + return + + await asyncio.wait_for(consume(), timeout=2.0) + assert seen == [{"i": 0}, {"i": 1}] + + +@pytest.mark.asyncio +async def test_tail_waits_for_file_then_yields(tmp_path: Path) -> None: + p = tmp_path / "delayed.jsonl" + w = JsonlWatcher(p, poll_interval=0.01) + + seen: list[dict] = [] + + async def consume() -> None: + async for rec in w.tail(): + seen.append(rec) + if len(seen) >= 1: + return + + async def produce() -> None: + await asyncio.sleep(0.05) + _write_records(p, [{"late": True}]) + + consumer = asyncio.create_task(consume()) + producer = asyncio.create_task(produce()) + await asyncio.wait_for(asyncio.gather(consumer, producer), timeout=2.0) + assert seen == [{"late": True}] + + +@pytest.mark.asyncio +async def test_tail_streams_incremental_appends(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + p.write_text("", encoding="utf-8") + w = JsonlWatcher(p, poll_interval=0.01) + + seen: list[dict] = [] + target = [{"i": 0}, {"i": 1}, {"i": 2}, {"i": 3}] + + async def consume() -> None: + async for rec in w.tail(): + seen.append(rec) + if len(seen) >= len(target): + return + + async def produce() -> None: + for rec in target: + _write_records(p, [rec]) + await asyncio.sleep(0.02) + + consumer = asyncio.create_task(consume()) + producer = asyncio.create_task(produce()) + await asyncio.wait_for(asyncio.gather(consumer, producer), timeout=3.0) + assert seen == target + + +@pytest.mark.asyncio +async def test_tail_handles_appends_arriving_mid_line(tmp_path: Path) -> None: + """A record split across two writes (no newline in the first) must arrive + as one parsed record once the second chunk lands.""" + p = tmp_path / "s.jsonl" + p.write_text("", encoding="utf-8") + w = JsonlWatcher(p, poll_interval=0.01) + + seen: list[dict] = [] + + async def consume() -> None: + async for rec in w.tail(): + seen.append(rec) + if len(seen) >= 1: + return + + async def produce() -> None: + # Write the first half, sleep past at least one poll, then the rest. + with p.open("a", encoding="utf-8") as f: + f.write('{"split":') + f.flush() + await asyncio.sleep(0.05) + with p.open("a", encoding="utf-8") as f: + f.write(" true}\n") + f.flush() + + consumer = asyncio.create_task(consume()) + producer = asyncio.create_task(produce()) + await asyncio.wait_for(asyncio.gather(consumer, producer), timeout=2.0) + assert seen == [{"split": True}] + + +@pytest.mark.asyncio +async def test_tail_is_cancellable(tmp_path: Path) -> None: + p = tmp_path / "s.jsonl" + p.write_text("", encoding="utf-8") + w = JsonlWatcher(p, poll_interval=0.01) + + async def consume() -> None: + async for _ in w.tail(): + pass + + task = asyncio.create_task(consume()) + # Give it a few poll ticks to settle into the idle loop, then cancel. + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task diff --git a/ty.toml b/ty.toml new file mode 100644 index 0000000..69c4239 --- /dev/null +++ b/ty.toml @@ -0,0 +1,5 @@ +[environment] +python = ".venv" + +[src] +exclude = ["tests", "examples", "scripts"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b7fa064 --- /dev/null +++ b/uv.lock @@ -0,0 +1,273 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "claude-code-api" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "ptyprocess" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [{ name = "ptyprocess", specifier = ">=0.7" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=4.0" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, +]