feat: vibed out some slop over here also
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
.venv
|
||||
|
||||
.idea
|
||||
t
|
||||
|
||||
.coverage
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
@@ -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
|
||||
@@ -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/<key>/<id>.jsonl` and spawns
|
||||
`claude --resume <id>`. 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.
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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/<latest>
|
||||
$(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())
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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/<key>/<session_id>.jsonl`, then start claude
|
||||
with `--resume <session_id>`. 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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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/<key>/<session_id>.jsonl` and resumed via
|
||||
`claude --resume <session_id>`.
|
||||
|
||||
- `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/<key>/<session_id>.jsonl` and spawns
|
||||
`claude --resume <session_id>`. 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"]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Hardcoded inventory of `claude` CLI models and aliases.
|
||||
|
||||
Sourced by inspecting the Mach-O binary at
|
||||
`~/.local/share/claude/versions/<ver>` 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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Helpers for locating `claude` JSONL session files.
|
||||
|
||||
`claude` stores per-session transcripts at
|
||||
`~/.claude/projects/<project_key>/<session_id>.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/**/<session_id>.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",
|
||||
)
|
||||
@@ -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 <id>` instead of
|
||||
`--session-id <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",
|
||||
)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Polling tail of a `claude` JSONL session file.
|
||||
|
||||
`JsonlWatcher` watches a single `<session_id>.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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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": "<local-command-caveat>...</...>"},
|
||||
}
|
||||
)
|
||||
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": "<local-command-stdout></local-command-stdout>",
|
||||
}
|
||||
)
|
||||
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"
|
||||
@@ -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)
|
||||
@@ -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 <fresh>` for `--resume <existing>`.
|
||||
|
||||
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}"
|
||||
)
|
||||
@@ -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__<server>__<tool>`, 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
[environment]
|
||||
python = ".venv"
|
||||
|
||||
[src]
|
||||
exclude = ["tests", "examples", "scripts"]
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user