Extract client CLI adapter abstraction

This commit is contained in:
Alishahryar1
2026-06-02 22:19:45 -07:00
parent 2b70e26890
commit d3fc2031bb
10 changed files with 704 additions and 142 deletions
+11
View File
@@ -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",
]
+78
View File
@@ -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."""
...
+222
View File
@@ -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()
+23
View File
@@ -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
+28 -26
View File
@@ -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:
+5
View File
@@ -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
+32 -115
View File
@@ -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."""
+176
View File
@@ -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"}]
+103 -1
View File
@@ -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."""
+26
View File
@@ -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