From d3fc2031bb01bc31d31c614209bf6abb085d409f Mon Sep 17 00:00:00 2001 From: Alishahryar1 Date: Tue, 2 Jun 2026 22:19:45 -0700 Subject: [PATCH] Extract client CLI adapter abstraction --- cli/adapters/__init__.py | 11 ++ cli/adapters/base.py | 78 ++++++++ cli/adapters/claude.py | 222 +++++++++++++++++++++++ cli/adapters/registry.py | 23 +++ cli/entrypoints.py | 54 +++--- cli/manager.py | 5 + cli/session.py | 147 ++++----------- tests/cli/test_adapters.py | 176 ++++++++++++++++++ tests/cli/test_cli.py | 104 ++++++++++- tests/cli/test_cli_manager_edge_cases.py | 26 +++ 10 files changed, 704 insertions(+), 142 deletions(-) create mode 100644 cli/adapters/__init__.py create mode 100644 cli/adapters/base.py create mode 100644 cli/adapters/claude.py create mode 100644 cli/adapters/registry.py create mode 100644 tests/cli/test_adapters.py diff --git a/cli/adapters/__init__.py b/cli/adapters/__init__.py new file mode 100644 index 00000000..866e4159 --- /dev/null +++ b/cli/adapters/__init__.py @@ -0,0 +1,11 @@ +"""Client CLI adapter implementations.""" + +from .claude import CLAUDE_CLI_ADAPTER, ClaudeCliAdapter +from .registry import DEFAULT_CLIENT_CLI_ID, get_client_cli_adapter + +__all__ = [ + "CLAUDE_CLI_ADAPTER", + "DEFAULT_CLIENT_CLI_ID", + "ClaudeCliAdapter", + "get_client_cli_adapter", +] diff --git a/cli/adapters/base.py b/cli/adapters/base.py new file mode 100644 index 00000000..ec555803 --- /dev/null +++ b/cli/adapters/base.py @@ -0,0 +1,78 @@ +"""Shared contracts for client CLI subprocess adapters.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from typing import Any, Protocol + + +@dataclass(frozen=True, slots=True) +class CliTaskRequest: + """A single prompt execution request for a managed client CLI process.""" + + prompt: str + session_id: str | None = None + fork_session: bool = False + + +@dataclass(frozen=True, slots=True) +class CliInvocation: + """Concrete subprocess invocation assembled by a client CLI adapter.""" + + argv: tuple[str, ...] + env: dict[str, str] + cwd: str + trace_metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class CliParseState: + """Mutable line-parser state for a single client CLI process run.""" + + log_raw_cli_diagnostics: bool = False + session_id_extracted: bool = False + + +class ClientCliAdapter(Protocol): + """Adapter boundary for client CLI command/env construction and output parsing.""" + + id: str + display_name: str + default_binary: str + install_hint: str + trace_stage: str + process_launch_event: str + trace_source: str + + def build_task_invocation( + self, + *, + config: Any, + request: CliTaskRequest, + base_env: Mapping[str, str], + ) -> CliInvocation: + """Build the subprocess invocation for a managed task run.""" + ... + + def parse_stdout_line(self, line: str, state: CliParseState) -> Iterable[Any]: + """Parse one stdout line into parser-ready internal CLI events.""" + ... + + def extract_session_id(self, event: Any) -> str | None: + """Extract a persistent client CLI session id from a parsed event.""" + ... + + def get_launcher_binary_name(self, settings: Any) -> str: + """Return the configured executable name for a wrapper entrypoint.""" + ... + + def build_launcher_env( + self, + *, + proxy_root_url: str, + auth_token: str, + base_env: Mapping[str, str], + ) -> dict[str, str]: + """Build environment variables for a wrapper-launched client CLI.""" + ... diff --git a/cli/adapters/claude.py b/cli/adapters/claude.py new file mode 100644 index 00000000..0fde69f2 --- /dev/null +++ b/cli/adapters/claude.py @@ -0,0 +1,222 @@ +"""Claude Code client CLI adapter.""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping +from typing import Any + +from loguru import logger + +from .base import CliInvocation, CliParseState, CliTaskRequest + +_AUTO_COMPACT_WINDOW = "190000" + + +class ClaudeCliAdapter: + """Client CLI adapter for Claude Code.""" + + id = "claude" + display_name = "Claude Code" + default_binary = "claude" + install_hint = "Install Claude Code with: npm install -g @anthropic-ai/claude-code" + trace_stage = "claude_cli" + process_launch_event = "claude_cli.process.launch" + trace_source = "claude_cli" + + def build_task_invocation( + self, + *, + config: Any, + request: CliTaskRequest, + base_env: Mapping[str, str], + ) -> CliInvocation: + """Build a Claude Code stream-json subprocess invocation.""" + + env = self._task_env( + api_url=config.api_url, + auth_token=config.auth_token, + base_env=base_env, + ) + cmd = self._task_command( + claude_bin=config.claude_bin, + prompt=request.prompt, + session_id=request.session_id, + fork_session=request.fork_session, + allowed_dirs=config.allowed_dirs, + plans_directory=config.plans_directory, + ) + + resume_session_id = ( + request.session_id + if request.session_id and not request.session_id.startswith("pending_") + else None + ) + return CliInvocation( + argv=tuple(cmd), + env=env, + cwd=config.workspace_path, + trace_metadata={ + "client_cli_id": self.id, + "resume_session_id": resume_session_id, + "fork_session": request.fork_session, + "prompt": request.prompt, + "cwd": config.workspace_path, + "claude_binary": config.claude_bin, + "cli_argv": cmd, + }, + ) + + def parse_stdout_line(self, line: str, state: CliParseState) -> Iterable[Any]: + """Parse one Claude Code JSONL line into existing parser-ready events.""" + + try: + event = json.loads(line) + except json.JSONDecodeError: + if state.log_raw_cli_diagnostics: + logger.debug("Non-JSON output: {}", line) + else: + logger.debug("Non-JSON CLI line: char_len={}", len(line)) + yield {"type": "raw", "content": line} + return + + if not state.session_id_extracted: + extracted_id = self.extract_session_id(event) + if extracted_id: + state.session_id_extracted = True + logger.info(f"Extracted session ID: {extracted_id}") + yield {"type": "session_info", "session_id": extracted_id} + + yield event + + def extract_session_id(self, event: Any) -> str | None: + """Extract a Claude Code session ID from supported event shapes.""" + + if not isinstance(event, dict): + return None + + if session_id := _string_value(event.get("session_id")): + return session_id + if session_id := _string_value(event.get("sessionId")): + return session_id + + for key in ["init", "system", "result", "metadata"]: + nested = event.get(key) + if not isinstance(nested, dict): + continue + if session_id := _string_value(nested.get("session_id")): + return session_id + if session_id := _string_value(nested.get("sessionId")): + return session_id + + conv = event.get("conversation") + if isinstance(conv, dict): + return _string_value(conv.get("id")) + + return None + + def get_launcher_binary_name(self, settings: Any) -> str: + """Return the configured Claude Code binary name.""" + + configured = getattr(settings, "claude_cli_bin", "") + return configured or self.default_binary + + def build_launcher_env( + self, + *, + proxy_root_url: str, + auth_token: str, + base_env: Mapping[str, str], + ) -> dict[str, str]: + """Return a Claude Code environment that targets the local proxy.""" + + env = { + key: value + for key, value in base_env.items() + if not key.startswith("ANTHROPIC_") + } + env.pop("ANTHROPIC_API_KEY", None) + env["ANTHROPIC_BASE_URL"] = proxy_root_url + env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1" + env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = _AUTO_COMPACT_WINDOW + if token := auth_token.strip(): + env["ANTHROPIC_AUTH_TOKEN"] = token + return env + + def _task_env( + self, + *, + api_url: str, + auth_token: str, + base_env: Mapping[str, str], + ) -> dict[str, str]: + env = dict(base_env) + env["ANTHROPIC_API_URL"] = api_url + if api_url.endswith("/v1"): + env["ANTHROPIC_BASE_URL"] = api_url[:-3] + else: + env["ANTHROPIC_BASE_URL"] = api_url + env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1" + env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = _AUTO_COMPACT_WINDOW + env.pop("ANTHROPIC_API_KEY", None) + if token := auth_token.strip(): + env["ANTHROPIC_AUTH_TOKEN"] = token + else: + env.pop("ANTHROPIC_AUTH_TOKEN", None) + + env["TERM"] = "dumb" + env["PYTHONIOENCODING"] = "utf-8" + return env + + def _task_command( + self, + *, + claude_bin: str, + prompt: str, + session_id: str | None, + fork_session: bool, + allowed_dirs: list[str], + plans_directory: str | None, + ) -> list[str]: + if session_id and not session_id.startswith("pending_"): + cmd = [ + claude_bin, + "--resume", + session_id, + ] + if fork_session: + cmd.append("--fork-session") + cmd += [ + "-p", + prompt, + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--verbose", + ] + else: + cmd = [ + claude_bin, + "-p", + prompt, + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--verbose", + ] + + for directory in allowed_dirs: + cmd.extend(["--add-dir", directory]) + + if plans_directory is not None: + settings_json = json.dumps({"plansDirectory": plans_directory}) + cmd.extend(["--settings", settings_json]) + + return cmd + + +def _string_value(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +CLAUDE_CLI_ADAPTER = ClaudeCliAdapter() diff --git a/cli/adapters/registry.py b/cli/adapters/registry.py new file mode 100644 index 00000000..05899b86 --- /dev/null +++ b/cli/adapters/registry.py @@ -0,0 +1,23 @@ +"""Internal client CLI adapter registry.""" + +from __future__ import annotations + +from .base import ClientCliAdapter +from .claude import CLAUDE_CLI_ADAPTER + +DEFAULT_CLIENT_CLI_ID = "claude" + +_ADAPTERS: dict[str, ClientCliAdapter] = { + CLAUDE_CLI_ADAPTER.id: CLAUDE_CLI_ADAPTER, +} + + +def get_client_cli_adapter( + client_cli_id: str = DEFAULT_CLIENT_CLI_ID, +) -> ClientCliAdapter: + """Return a registered client CLI adapter by id.""" + + try: + return _ADAPTERS[client_cli_id] + except KeyError as exc: + raise ValueError(f"Unknown client CLI adapter: {client_cli_id}") from exc diff --git a/cli/entrypoints.py b/cli/entrypoints.py index 2da2ed31..a536e702 100644 --- a/cli/entrypoints.py +++ b/cli/entrypoints.py @@ -18,6 +18,8 @@ import uvicorn from api.admin_urls import local_admin_url, local_proxy_root_url from api.app import GracefulLifespanApp, create_app +from cli.adapters.base import ClientCliAdapter +from cli.adapters.claude import CLAUDE_CLI_ADAPTER from cli.process_registry import ( kill_all_best_effort, kill_pid_tree_best_effort, @@ -174,18 +176,11 @@ def _claude_child_env( ) -> dict[str, str]: """Return a Claude Code environment that targets this proxy.""" - env = { - key: value - for key, value in base_env.items() - if not key.startswith("ANTHROPIC_") - } - env.pop("ANTHROPIC_API_KEY", None) - env["ANTHROPIC_BASE_URL"] = local_proxy_root_url(settings) - env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1" - env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "190000" - if token := settings.anthropic_auth_token.strip(): - env["ANTHROPIC_AUTH_TOKEN"] = token - return env + return CLAUDE_CLI_ADAPTER.build_launcher_env( + proxy_root_url=local_proxy_root_url(settings), + auth_token=settings.anthropic_auth_token, + base_env=base_env, + ) def _preflight_proxy(proxy_root_url: str) -> str | None: @@ -211,6 +206,14 @@ def _preflight_proxy(proxy_root_url: str) -> str | None: def launch_claude(argv: Sequence[str] | None = None) -> None: """Launch Claude Code with Free Claude Code proxy environment variables.""" + _launch_client_cli(CLAUDE_CLI_ADAPTER, argv) + + +def _launch_client_cli( + adapter: ClientCliAdapter, argv: Sequence[str] | None = None +) -> None: + """Launch a client CLI with Free Claude Code proxy environment variables.""" + settings = get_settings() proxy_root_url = local_proxy_root_url(settings) if error := _preflight_proxy(proxy_root_url): @@ -222,20 +225,22 @@ def launch_claude(argv: Sequence[str] | None = None) -> None: raise SystemExit(1) args = list(sys.argv[1:] if argv is None else argv) - claude_command = shutil.which(settings.claude_cli_bin) - if claude_command is None: + binary_name = adapter.get_launcher_binary_name(settings) + client_command = shutil.which(binary_name) + if client_command is None: print( - f"Could not find Claude Code command: {settings.claude_cli_bin}", - file=sys.stderr, - ) - print( - "Install Claude Code with: npm install -g @anthropic-ai/claude-code", + f"Could not find {adapter.display_name} command: {binary_name}", file=sys.stderr, ) + print(adapter.install_hint, file=sys.stderr) raise SystemExit(127) - command = [claude_command, *args] - env = _claude_child_env(settings, os.environ) + command = [client_command, *args] + env = adapter.build_launcher_env( + proxy_root_url=proxy_root_url, + auth_token=settings.anthropic_auth_token, + base_env=os.environ, + ) process: subprocess.Popen[bytes] | None = None try: process = subprocess.Popen(command, env=env) @@ -244,13 +249,10 @@ def launch_claude(argv: Sequence[str] | None = None) -> None: return_code = process.wait() except FileNotFoundError: print( - f"Could not find Claude Code command: {settings.claude_cli_bin}", - file=sys.stderr, - ) - print( - "Install Claude Code with: npm install -g @anthropic-ai/claude-code", + f"Could not find {adapter.display_name} command: {binary_name}", file=sys.stderr, ) + print(adapter.install_hint, file=sys.stderr) raise SystemExit(127) from None except KeyboardInterrupt: if process is not None and process.pid: diff --git a/cli/manager.py b/cli/manager.py index 303c01e0..7f080f0b 100644 --- a/cli/manager.py +++ b/cli/manager.py @@ -11,6 +11,8 @@ import uuid from loguru import logger +from .adapters.base import ClientCliAdapter +from .adapters.registry import get_client_cli_adapter from .session import CLISession @@ -31,6 +33,7 @@ class CLISessionManager: claude_bin: str = "claude", auth_token: str = "", *, + client_cli_adapter: ClientCliAdapter | None = None, log_raw_cli_diagnostics: bool = False, log_messaging_error_details: bool = False, ): @@ -49,6 +52,7 @@ class CLISessionManager: self.plans_directory = plans_directory self.claude_bin = claude_bin self.auth_token = auth_token + self._client_cli_adapter = client_cli_adapter or get_client_cli_adapter() self._log_raw_cli_diagnostics = log_raw_cli_diagnostics self._log_messaging_error_details = log_messaging_error_details @@ -85,6 +89,7 @@ class CLISessionManager: plans_directory=self.plans_directory, claude_bin=self.claude_bin, auth_token=self.auth_token, + client_cli_adapter=self._client_cli_adapter, log_raw_cli_diagnostics=self._log_raw_cli_diagnostics, ) self._pending_sessions[temp_id] = new_session diff --git a/cli/session.py b/cli/session.py index fc11f62f..19801fd3 100644 --- a/cli/session.py +++ b/cli/session.py @@ -1,7 +1,6 @@ """Claude Code CLI session management.""" import asyncio -import json import os from collections.abc import AsyncGenerator from dataclasses import dataclass, field @@ -11,6 +10,8 @@ from loguru import logger from core.trace import trace_event +from .adapters.base import ClientCliAdapter, CliParseState, CliTaskRequest +from .adapters.registry import get_client_cli_adapter from .process_registry import kill_pid_tree_best_effort, register_pid, unregister_pid # Cap stderr capture so a runaway child cannot exhaust memory; pipe is still drained. @@ -41,6 +42,7 @@ class CLISession: claude_bin: str = "claude", auth_token: str = "", *, + client_cli_adapter: ClientCliAdapter | None = None, log_raw_cli_diagnostics: bool = False, ): self.config = ClaudeCliConfig( @@ -57,6 +59,7 @@ class CLISession: self.plans_directory = self.config.plans_directory self.claude_bin = self.config.claude_bin self.auth_token = self.config.auth_token + self._client_cli_adapter = client_cli_adapter or get_client_cli_adapter() self._log_raw_cli_diagnostics = log_raw_cli_diagnostics self.process: asyncio.subprocess.Process | None = None self.current_session_id: str | None = None @@ -111,83 +114,30 @@ class CLISession: """ async with self._cli_lock: self._is_busy = True - env = os.environ.copy() - - env["ANTHROPIC_API_URL"] = self.api_url - if self.api_url.endswith("/v1"): - env["ANTHROPIC_BASE_URL"] = self.api_url[:-3] - else: - env["ANTHROPIC_BASE_URL"] = self.api_url - env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1" - env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "190000" - env.pop("ANTHROPIC_API_KEY", None) - if token := self.auth_token.strip(): - env["ANTHROPIC_AUTH_TOKEN"] = token - else: - env.pop("ANTHROPIC_AUTH_TOKEN", None) - - env["TERM"] = "dumb" - env["PYTHONIOENCODING"] = "utf-8" - - # Build command - if session_id and not session_id.startswith("pending_"): - cmd = [ - self.claude_bin, - "--resume", - session_id, - ] - if fork_session: - cmd.append("--fork-session") - cmd += [ - "-p", - prompt, - "--output-format", - "stream-json", - "--dangerously-skip-permissions", - "--verbose", - ] - else: - cmd = [ - self.claude_bin, - "-p", - prompt, - "--output-format", - "stream-json", - "--dangerously-skip-permissions", - "--verbose", - ] - - if self.allowed_dirs: - for d in self.allowed_dirs: - cmd.extend(["--add-dir", d]) - - if self.plans_directory is not None: - settings_json = json.dumps({"plansDirectory": self.plans_directory}) - cmd.extend(["--settings", settings_json]) + invocation = self._client_cli_adapter.build_task_invocation( + config=self.config, + request=CliTaskRequest( + prompt=prompt, + session_id=session_id, + fork_session=fork_session, + ), + base_env=os.environ, + ) trace_event( - stage="claude_cli", - event="claude_cli.process.launch", - source="claude_cli", - resume_session_id=( - session_id - if session_id and not session_id.startswith("pending_") - else None - ), - fork_session=fork_session, - prompt=prompt, - cwd=self.workspace, - claude_binary=self.claude_bin, - cli_argv=cmd, + stage=self._client_cli_adapter.trace_stage, + event=self._client_cli_adapter.process_launch_event, + source=self._client_cli_adapter.trace_source, + **invocation.trace_metadata, ) try: self.process = await asyncio.create_subprocess_exec( - *cmd, + *invocation.argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - cwd=self.workspace, - env=env, + cwd=invocation.cwd, + env=invocation.env, ) if self.process and self.process.pid: register_pid(self.process.pid) @@ -196,7 +146,9 @@ class CLISession: yield {"type": "exit", "code": 1} return - session_id_extracted = False + parse_state = CliParseState( + log_raw_cli_diagnostics=self._log_raw_cli_diagnostics + ) buffer = bytearray() stderr_task: asyncio.Task[bytes] | None = None if self.process.stderr: @@ -214,10 +166,8 @@ class CLISession: ).strip() if line_str: async for event in self._handle_line_gen( - line_str, session_id_extracted + line_str, parse_state ): - if event.get("type") == "session_info": - session_id_extracted = True yield event break @@ -234,10 +184,8 @@ class CLISession: line_str = line.decode("utf-8", errors="replace").strip() if line_str: async for event in self._handle_line_gen( - line_str, session_id_extracted + line_str, parse_state ): - if event.get("type") == "session_info": - session_id_extracted = True yield event except asyncio.CancelledError: # Cancelling the handler task should not leave a Claude CLI @@ -283,50 +231,19 @@ class CLISession: unregister_pid(self.process.pid) async def _handle_line_gen( - self, line_str: str, session_id_extracted: bool + self, line_str: str, parse_state: CliParseState ) -> AsyncGenerator[dict]: """Process a single line and yield events.""" - try: - event = json.loads(line_str) - if not session_id_extracted: - extracted_id = self._extract_session_id(event) - if extracted_id: - self.current_session_id = extracted_id - logger.info(f"Extracted session ID: {extracted_id}") - yield {"type": "session_info", "session_id": extracted_id} - + for event in self._client_cli_adapter.parse_stdout_line(line_str, parse_state): + if isinstance(event, dict) and event.get("type") == "session_info": + session_id = event.get("session_id") + if isinstance(session_id, str): + self.current_session_id = session_id yield event - except json.JSONDecodeError: - if self._log_raw_cli_diagnostics: - logger.debug("Non-JSON output: {}", line_str) - else: - logger.debug("Non-JSON CLI line: char_len={}", len(line_str)) - yield {"type": "raw", "content": line_str} def _extract_session_id(self, event: Any) -> str | None: """Extract session ID from CLI event.""" - if not isinstance(event, dict): - return None - - if "session_id" in event: - return event["session_id"] - if "sessionId" in event: - return event["sessionId"] - - for key in ["init", "system", "result", "metadata"]: - if key in event and isinstance(event[key], dict): - nested = event[key] - if "session_id" in nested: - return nested["session_id"] - if "sessionId" in nested: - return nested["sessionId"] - - if "conversation" in event and isinstance(event["conversation"], dict): - conv = event["conversation"] - if "id" in conv: - return conv["id"] - - return None + return self._client_cli_adapter.extract_session_id(event) async def stop(self): """Stop the CLI process.""" diff --git a/tests/cli/test_adapters.py b/tests/cli/test_adapters.py new file mode 100644 index 00000000..e80878ca --- /dev/null +++ b/tests/cli/test_adapters.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +from cli.adapters.base import CliParseState, CliTaskRequest +from cli.adapters.claude import CLAUDE_CLI_ADAPTER +from cli.adapters.registry import DEFAULT_CLIENT_CLI_ID, get_client_cli_adapter + + +def _config(**overrides: object) -> SimpleNamespace: + values: dict[str, object] = { + "workspace_path": "/workspace", + "api_url": "http://127.0.0.1:8082/v1", + "allowed_dirs": [], + "plans_directory": None, + "claude_bin": "claude-test", + "auth_token": "", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def test_registry_returns_default_claude_adapter() -> None: + assert DEFAULT_CLIENT_CLI_ID == "claude" + assert get_client_cli_adapter() is CLAUDE_CLI_ADAPTER + assert get_client_cli_adapter("claude") is CLAUDE_CLI_ADAPTER + + +def test_claude_adapter_builds_new_task_command_and_env() -> None: + invocation = CLAUDE_CLI_ADAPTER.build_task_invocation( + config=_config(auth_token="proxy-token"), + request=CliTaskRequest(prompt="hello"), + base_env={ + "KEEP_ME": "yes", + "ANTHROPIC_API_KEY": "official-key", + "ANTHROPIC_AUTH_TOKEN": "stale-token", + }, + ) + + assert invocation.argv == ( + "claude-test", + "-p", + "hello", + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--verbose", + ) + assert invocation.env["KEEP_ME"] == "yes" + assert invocation.env["ANTHROPIC_API_URL"] == "http://127.0.0.1:8082/v1" + assert invocation.env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8082" + assert invocation.env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" + assert invocation.env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert invocation.env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000" + assert "ANTHROPIC_API_KEY" not in invocation.env + assert invocation.trace_metadata["client_cli_id"] == "claude" + + +def test_claude_adapter_builds_resume_fork_command() -> None: + invocation = CLAUDE_CLI_ADAPTER.build_task_invocation( + config=_config(), + request=CliTaskRequest( + prompt="continue", + session_id="sess_123", + fork_session=True, + ), + base_env={}, + ) + + assert invocation.argv[:4] == ( + "claude-test", + "--resume", + "sess_123", + "--fork-session", + ) + assert "-p" in invocation.argv + assert "continue" in invocation.argv + assert invocation.trace_metadata["resume_session_id"] == "sess_123" + assert invocation.trace_metadata["fork_session"] is True + + +def test_claude_adapter_does_not_resume_pending_session() -> None: + invocation = CLAUDE_CLI_ADAPTER.build_task_invocation( + config=_config(), + request=CliTaskRequest(prompt="new", session_id="pending_123"), + base_env={}, + ) + + assert "--resume" not in invocation.argv + assert invocation.trace_metadata["resume_session_id"] is None + + +def test_claude_adapter_adds_allowed_dirs_and_plans_directory() -> None: + invocation = CLAUDE_CLI_ADAPTER.build_task_invocation( + config=_config( + allowed_dirs=["/dir1", "/dir2"], + plans_directory="./agent_workspace/plans", + ), + request=CliTaskRequest(prompt="hello"), + base_env={}, + ) + + assert invocation.argv.count("--add-dir") == 2 + assert "/dir1" in invocation.argv + assert "/dir2" in invocation.argv + settings_idx = invocation.argv.index("--settings") + settings = json.loads(invocation.argv[settings_idx + 1]) + assert settings["plansDirectory"] == "./agent_workspace/plans" + + +def test_claude_adapter_launcher_env_targets_proxy() -> None: + env = CLAUDE_CLI_ADAPTER.build_launcher_env( + proxy_root_url="http://127.0.0.1:9191", + auth_token=" proxy-token ", + base_env={ + "PATH": "keep", + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_API_KEY": "official-key", + "ANTHROPIC_AUTH_TOKEN": "stale-token", + }, + ) + + assert env["PATH"] == "keep" + assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9191" + assert env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" + assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000" + assert "ANTHROPIC_API_KEY" not in env + + +def test_claude_adapter_extracts_supported_session_id_shapes() -> None: + assert CLAUDE_CLI_ADAPTER.extract_session_id({"session_id": "direct"}) == "direct" + assert CLAUDE_CLI_ADAPTER.extract_session_id({"sessionId": "camel"}) == "camel" + assert ( + CLAUDE_CLI_ADAPTER.extract_session_id({"init": {"session_id": "nested"}}) + == "nested" + ) + assert ( + CLAUDE_CLI_ADAPTER.extract_session_id({"result": {"sessionId": "result"}}) + == "result" + ) + assert ( + CLAUDE_CLI_ADAPTER.extract_session_id({"conversation": {"id": "conv"}}) + == "conv" + ) + assert CLAUDE_CLI_ADAPTER.extract_session_id({"type": "message"}) is None + assert CLAUDE_CLI_ADAPTER.extract_session_id("not a dict") is None + + +def test_claude_adapter_invalid_stdout_json_becomes_raw_event() -> None: + events = list( + CLAUDE_CLI_ADAPTER.parse_stdout_line( + "Not valid json", + CliParseState(log_raw_cli_diagnostics=False), + ) + ) + + assert events == [{"type": "raw", "content": "Not valid json"}] + + +def test_claude_adapter_synthesizes_session_info_once() -> None: + state = CliParseState() + + first_events = list( + CLAUDE_CLI_ADAPTER.parse_stdout_line('{"session_id": "sess_1"}', state) + ) + second_events = list( + CLAUDE_CLI_ADAPTER.parse_stdout_line('{"session_id": "sess_2"}', state) + ) + + assert first_events == [ + {"type": "session_info", "session_id": "sess_1"}, + {"session_id": "sess_1"}, + ] + assert second_events == [{"session_id": "sess_2"}] diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 2b3e282f..82b40baf 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -3,7 +3,8 @@ import asyncio import json import os -from typing import cast +from collections.abc import Iterable, Mapping +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -639,6 +640,107 @@ class TestCLISession: assert len(events) == 1 assert events[0]["content"] == "Not valid json" + @pytest.mark.asyncio + async def test_start_task_uses_injected_client_cli_adapter(self, tmp_path): + """CLISession delegates argv/env/line parsing to the injected adapter.""" + from cli.adapters.base import CliInvocation, CliParseState, CliTaskRequest + from cli.session import CLISession + + class FakeClientCliAdapter: + id = "fake" + display_name = "Fake CLI" + default_binary = "fake" + install_hint = "Install Fake CLI" + trace_stage = "claude_cli" + process_launch_event = "claude_cli.process.launch" + trace_source = "claude_cli" + + def __init__(self) -> None: + self.request: CliTaskRequest | None = None + + def build_task_invocation( + self, + *, + config: Any, + request: CliTaskRequest, + base_env: Mapping[str, str], + ) -> CliInvocation: + self.request = request + return CliInvocation( + argv=("fake-cli", "--prompt", request.prompt), + env={"FAKE_ENV": base_env.get("KEEP_ME", "")}, + cwd=config.workspace_path, + trace_metadata={"client_cli_id": self.id}, + ) + + def parse_stdout_line( + self, line: str, state: CliParseState + ) -> Iterable[dict[str, Any]]: + if not state.session_id_extracted: + state.session_id_extracted = True + yield {"type": "session_info", "session_id": "fake_session"} + yield {"type": "message", "content": line} + + def extract_session_id(self, event: Any) -> str | None: + if isinstance(event, dict): + value = event.get("session_id") + return value if isinstance(value, str) else None + return None + + def get_launcher_binary_name(self, settings: Any) -> str: + return self.default_binary + + def build_launcher_env( + self, + *, + proxy_root_url: str, + auth_token: str, + base_env: Mapping[str, str], + ) -> dict[str, str]: + return dict(base_env) + + adapter = FakeClientCliAdapter() + session = CLISession( + str(tmp_path), + "http://localhost:8082/v1", + client_cli_adapter=adapter, + ) + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b"hello\n", b""] + mock_process.stderr.read.return_value = b"" + mock_process.wait.return_value = 0 + mock_process.returncode = 0 + + with ( + patch.dict(os.environ, {"KEEP_ME": "yes"}, clear=False), + patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec, + ): + mock_exec.return_value = mock_process + events = [ + e + async for e in session.start_task( + "adapter prompt", + session_id="sess_fake", + fork_session=True, + ) + ] + + assert adapter.request == CliTaskRequest( + prompt="adapter prompt", + session_id="sess_fake", + fork_session=True, + ) + assert mock_exec.call_args.args == ("fake-cli", "--prompt", "adapter prompt") + assert mock_exec.call_args.kwargs["env"] == {"FAKE_ENV": "yes"} + assert session.current_session_id == "fake_session" + assert events[:2] == [ + {"type": "session_info", "session_id": "fake_session"}, + {"type": "message", "content": "hello"}, + ] + @pytest.mark.asyncio async def test_stop_exception(self): """Test exception handling during stop.""" diff --git a/tests/cli/test_cli_manager_edge_cases.py b/tests/cli/test_cli_manager_edge_cases.py index 373916b3..e0f4a8ab 100644 --- a/tests/cli/test_cli_manager_edge_cases.py +++ b/tests/cli/test_cli_manager_edge_cases.py @@ -3,6 +3,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +def _fake_client_cli_adapter(): + adapter = MagicMock() + adapter.id = "fake" + return adapter + + @pytest.mark.asyncio async def test_register_real_session_id_moves_pending_to_active_and_maps(): from cli.manager import CLISessionManager @@ -104,3 +110,23 @@ async def test_stop_all_handles_stop_exceptions(): s2.stop.assert_awaited_once() assert manager.get_stats()["active_sessions"] == 0 assert manager.get_stats()["pending_sessions"] == 0 + + +@pytest.mark.asyncio +async def test_get_or_create_session_passes_client_cli_adapter(): + from cli.manager import CLISessionManager + + adapter = _fake_client_cli_adapter() + with patch("cli.manager.CLISession") as mock_session_cls: + mock_session = MagicMock() + mock_session.is_busy = False + mock_session_cls.return_value = mock_session + + manager = CLISessionManager( + workspace_path="/tmp", + api_url="http://x/v1", + client_cli_adapter=adapter, + ) + await manager.get_or_create_session() + + assert mock_session_cls.call_args.kwargs["client_cli_adapter"] is adapter