feat! Claude Code as a CLI agent now supported. Mix and match: spawn claude code from within claude code, or claude code from within codex.
Stay in codex, plan review and fix complicated bugs, then ask it to spawn claude code and implement the plan. This uses your current subscription instead of API tokens.
This commit is contained in:
111
tests/test_clink_claude_agent.py
Normal file
111
tests/test_clink_claude_agent.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from clink.agents.base import CLIAgentError
|
||||
from clink.agents.claude import ClaudeAgent
|
||||
from clink.models import ResolvedCLIClient, ResolvedCLIRole
|
||||
|
||||
|
||||
class DummyProcess:
|
||||
def __init__(self, *, stdout: bytes = b"", stderr: bytes = b"", returncode: int = 0):
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
self.returncode = returncode
|
||||
self.stdin_data: bytes | None = None
|
||||
|
||||
async def communicate(self, input_data):
|
||||
self.stdin_data = input_data
|
||||
return self._stdout, self._stderr
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def claude_agent():
|
||||
prompt_path = Path("systemprompts/clink/default.txt").resolve()
|
||||
role = ResolvedCLIRole(name="default", prompt_path=prompt_path, role_args=[])
|
||||
client = ResolvedCLIClient(
|
||||
name="claude",
|
||||
executable=["claude"],
|
||||
internal_args=["--print", "--output-format", "json"],
|
||||
config_args=["--permission-mode", "acceptEdits"],
|
||||
env={},
|
||||
timeout_seconds=30,
|
||||
parser="claude_json",
|
||||
runner="claude",
|
||||
roles={"default": role},
|
||||
output_to_file=None,
|
||||
working_dir=None,
|
||||
)
|
||||
return ClaudeAgent(client), role
|
||||
|
||||
|
||||
async def _run_agent_with_process(monkeypatch, agent, role, process, *, system_prompt="System prompt"):
|
||||
async def fake_create_subprocess_exec(*_args, **_kwargs):
|
||||
return process
|
||||
|
||||
def fake_which(executable_name):
|
||||
return f"/usr/bin/{executable_name}"
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
||||
monkeypatch.setattr(shutil, "which", fake_which)
|
||||
|
||||
return await agent.run(
|
||||
role=role,
|
||||
prompt="Respond with 42",
|
||||
system_prompt=system_prompt,
|
||||
files=[],
|
||||
images=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claude_agent_injects_system_prompt(monkeypatch, claude_agent):
|
||||
agent, role = claude_agent
|
||||
stdout_payload = json.dumps(
|
||||
{
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"is_error": False,
|
||||
"result": "42",
|
||||
}
|
||||
).encode()
|
||||
process = DummyProcess(stdout=stdout_payload)
|
||||
|
||||
result = await _run_agent_with_process(monkeypatch, agent, role, process)
|
||||
|
||||
assert "--append-system-prompt" in result.sanitized_command
|
||||
idx = result.sanitized_command.index("--append-system-prompt")
|
||||
assert result.sanitized_command[idx + 1] == "System prompt"
|
||||
assert process.stdin_data.decode().startswith("Respond with 42")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claude_agent_recovers_error_payload(monkeypatch, claude_agent):
|
||||
agent, role = claude_agent
|
||||
stdout_payload = json.dumps(
|
||||
{
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"is_error": True,
|
||||
"result": "API Error",
|
||||
}
|
||||
).encode()
|
||||
process = DummyProcess(stdout=stdout_payload, returncode=2)
|
||||
|
||||
result = await _run_agent_with_process(monkeypatch, agent, role, process)
|
||||
|
||||
assert result.returncode == 2
|
||||
assert result.parsed.content == "API Error"
|
||||
assert result.parsed.metadata["is_error"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claude_agent_propagates_unparseable_output(monkeypatch, claude_agent):
|
||||
agent, role = claude_agent
|
||||
process = DummyProcess(stdout=b"", returncode=1)
|
||||
|
||||
with pytest.raises(CLIAgentError):
|
||||
await _run_agent_with_process(monkeypatch, agent, role, process)
|
||||
45
tests/test_clink_claude_parser.py
Normal file
45
tests/test_clink_claude_parser.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Tests for the Claude CLI JSON parser."""
|
||||
|
||||
import pytest
|
||||
|
||||
from clink.parsers.base import ParserError
|
||||
from clink.parsers.claude import ClaudeJSONParser
|
||||
|
||||
|
||||
def _build_success_payload() -> str:
|
||||
return (
|
||||
'{"type":"result","subtype":"success","is_error":false,"duration_ms":1234,'
|
||||
'"duration_api_ms":1200,"num_turns":1,"result":"42","session_id":"abc","total_cost_usd":0.12,'
|
||||
'"usage":{"input_tokens":10,"output_tokens":5},'
|
||||
'"modelUsage":{"claude-sonnet-4-5-20250929":{"inputTokens":10,"outputTokens":5}}}'
|
||||
)
|
||||
|
||||
|
||||
def test_claude_parser_extracts_result_and_metadata():
|
||||
parser = ClaudeJSONParser()
|
||||
stdout = _build_success_payload()
|
||||
|
||||
parsed = parser.parse(stdout=stdout, stderr="")
|
||||
|
||||
assert parsed.content == "42"
|
||||
assert parsed.metadata["model_used"] == "claude-sonnet-4-5-20250929"
|
||||
assert parsed.metadata["usage"]["output_tokens"] == 5
|
||||
assert parsed.metadata["is_error"] is False
|
||||
|
||||
|
||||
def test_claude_parser_falls_back_to_message():
|
||||
parser = ClaudeJSONParser()
|
||||
stdout = '{"type":"result","is_error":true,"message":"API error message"}'
|
||||
|
||||
parsed = parser.parse(stdout=stdout, stderr="warning")
|
||||
|
||||
assert parsed.content == "API error message"
|
||||
assert parsed.metadata["is_error"] is True
|
||||
assert parsed.metadata["stderr"] == "warning"
|
||||
|
||||
|
||||
def test_claude_parser_requires_output():
|
||||
parser = ClaudeJSONParser()
|
||||
|
||||
with pytest.raises(ParserError):
|
||||
parser.parse(stdout="", stderr="")
|
||||
@@ -40,3 +40,41 @@ async def test_clink_gemini_single_digit_sum():
|
||||
if status == "continuation_available":
|
||||
offer = payload.get("continuation_offer") or {}
|
||||
assert offer.get("continuation_id"), "Expected continuation metadata when status indicates availability"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_clink_claude_single_digit_sum():
|
||||
if shutil.which("claude") is None:
|
||||
pytest.skip("claude CLI is not installed or on PATH")
|
||||
|
||||
tool = CLinkTool()
|
||||
prompt = "Respond with a single digit equal to the sum of 2 + 2. Output only that digit."
|
||||
|
||||
results = await tool.execute(
|
||||
{
|
||||
"prompt": prompt,
|
||||
"cli_name": "claude",
|
||||
"role": "default",
|
||||
"files": [],
|
||||
"images": [],
|
||||
}
|
||||
)
|
||||
|
||||
assert results, "clink tool returned no outputs"
|
||||
payload = json.loads(results[0].text)
|
||||
status = payload["status"]
|
||||
|
||||
if status == "error":
|
||||
metadata = payload.get("metadata") or {}
|
||||
reason = payload.get("content") or metadata.get("message") or "Claude CLI reported an error"
|
||||
pytest.skip(f"Skipping Claude integration test: {reason}")
|
||||
|
||||
assert status in {"success", "continuation_available"}
|
||||
|
||||
content = payload.get("content", "").strip()
|
||||
assert content == "4"
|
||||
|
||||
if status == "continuation_available":
|
||||
offer = payload.get("continuation_offer") or {}
|
||||
assert offer.get("continuation_id"), "Expected continuation metadata when status indicates availability"
|
||||
|
||||
Reference in New Issue
Block a user