feat: claude code runner (#9)
This commit is contained in:
+5
@@ -0,0 +1,5 @@
|
||||
{"type":"system","subtype":"init","session_id":"session_02","cwd":"/Users/banteg/dev/project","model":"sonnet","permissionMode":"manual","apiKeySource":"env","tools":["Bash","Read","Write"],"mcp_servers":[{"name":"approvals","status":"connected"}]}
|
||||
{"type":"assistant","session_id":"session_02","message":{"id":"msg_10","type":"message","role":"assistant","content":[{"type":"text","text":"I need permission to run this command."}],"usage":{"input_tokens":80,"output_tokens":20}}}
|
||||
{"type":"assistant","session_id":"session_02","parent_tool_use_id":"toolu_parent","message":{"id":"msg_11","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_9","name":"Bash","input":{"command":"git fetch origin main"}}]}}
|
||||
{"type":"user","session_id":"session_02","message":{"id":"msg_12","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_9","content":"permission denied"}]}}
|
||||
{"type":"result","subtype":"error","session_id":"session_02","total_cost_usd":0.001,"is_error":true,"duration_ms":2000,"duration_api_ms":1800,"num_turns":1,"result":"","error":"Permission denied","permission_denials":[{"tool_name":"Bash","tool_use_id":"toolu_9","tool_input":{"command":"git fetch origin main"}}]}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{"type":"system","subtype":"init","session_id":"session_01","cwd":"/Users/banteg/dev/project","model":"sonnet","permissionMode":"auto","apiKeySource":"env","tools":["Bash","Read","Write","WebSearch","Task"],"mcp_servers":[{"name":"approvals","status":"connected"}]}
|
||||
{"type":"assistant","session_id":"session_01","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet","content":[{"type":"text","text":"I'll inspect the repo, then add notes."}],"usage":{"input_tokens":120,"output_tokens":45}}}
|
||||
{"type":"assistant","session_id":"session_01","message":{"id":"msg_2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls -la"}}],"usage":{"input_tokens":10,"output_tokens":5}}}
|
||||
{"type":"user","session_id":"session_01","message":{"id":"msg_3","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":[{"type":"text","text":"total 2\nREADME.md\nsrc\n"}]}]}}
|
||||
{"type":"assistant","session_id":"session_01","message":{"id":"msg_4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_2","name":"Write","input":{"path":"notes.md","content":"hello"}}]}}
|
||||
{"type":"user","session_id":"session_01","message":{"id":"msg_5","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","content":"ok"}]}}
|
||||
{"type":"assistant","session_id":"session_01","message":{"id":"msg_6","type":"message","role":"assistant","content":[{"type":"text","text":"Done. Added notes.md."}],"usage":{"input_tokens":20,"output_tokens":12}}}
|
||||
{"type":"result","subtype":"success","session_id":"session_01","total_cost_usd":0.0123,"is_error":false,"duration_ms":12345,"duration_api_ms":12000,"num_turns":2,"result":"Done. Added notes.md.","usage":{"input_tokens":150,"output_tokens":70,"service_tier":"standard","server_tool_use":{"web_search_requests":0}},"modelUsage":{"sonnet":{"inputTokens":150,"outputTokens":70,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.0123,"contextWindow":200000}}}
|
||||
@@ -0,0 +1,276 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from takopi.model import ActionEvent, CompletedEvent, ResumeToken, StartedEvent
|
||||
from takopi.runners.claude import (
|
||||
ClaudeRunner,
|
||||
ClaudeStreamState,
|
||||
ENGINE,
|
||||
translate_claude_event,
|
||||
)
|
||||
|
||||
|
||||
def _load_fixture(name: str) -> list[dict]:
|
||||
path = Path(__file__).parent / "fixtures" / name
|
||||
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def test_claude_resume_format_and_extract() -> None:
|
||||
runner = ClaudeRunner(claude_cmd="claude")
|
||||
token = ResumeToken(engine=ENGINE, value="sid")
|
||||
|
||||
assert runner.format_resume(token) == "`claude --resume sid`"
|
||||
assert runner.extract_resume("`claude --resume sid`") == token
|
||||
assert runner.extract_resume("claude -r other") == ResumeToken(
|
||||
engine=ENGINE, value="other"
|
||||
)
|
||||
assert runner.extract_resume("`codex resume sid`") is None
|
||||
|
||||
|
||||
def test_translate_success_fixture() -> None:
|
||||
state = ClaudeStreamState()
|
||||
events: list = []
|
||||
for event in _load_fixture("claude_stream_success.jsonl"):
|
||||
events.extend(translate_claude_event(event, title="claude", state=state))
|
||||
|
||||
assert isinstance(events[0], StartedEvent)
|
||||
started = next(evt for evt in events if isinstance(evt, StartedEvent))
|
||||
|
||||
action_events = [evt for evt in events if isinstance(evt, ActionEvent)]
|
||||
assert len(action_events) == 4
|
||||
|
||||
started_actions = {
|
||||
(evt.action.id, evt.phase): evt
|
||||
for evt in action_events
|
||||
if evt.phase == "started"
|
||||
}
|
||||
assert started_actions[("toolu_1", "started")].action.kind == "command"
|
||||
write_action = started_actions[("toolu_2", "started")].action
|
||||
assert write_action.kind == "file_change"
|
||||
assert write_action.detail["changes"][0]["path"] == "notes.md"
|
||||
|
||||
completed_actions = {
|
||||
(evt.action.id, evt.phase): evt
|
||||
for evt in action_events
|
||||
if evt.phase == "completed"
|
||||
}
|
||||
assert completed_actions[("toolu_1", "completed")].ok is True
|
||||
assert completed_actions[("toolu_2", "completed")].ok is True
|
||||
|
||||
completed = next(evt for evt in events if isinstance(evt, CompletedEvent))
|
||||
assert events[-1] == completed
|
||||
assert completed.ok is True
|
||||
assert completed.resume == started.resume
|
||||
assert completed.answer == "Done. Added notes.md."
|
||||
|
||||
|
||||
def test_translate_error_fixture_permission_denials() -> None:
|
||||
state = ClaudeStreamState()
|
||||
events: list = []
|
||||
for event in _load_fixture("claude_stream_error.jsonl"):
|
||||
events.extend(translate_claude_event(event, title="claude", state=state))
|
||||
|
||||
started = next(evt for evt in events if isinstance(evt, StartedEvent))
|
||||
completed = next(evt for evt in events if isinstance(evt, CompletedEvent))
|
||||
warnings = [
|
||||
evt
|
||||
for evt in events
|
||||
if isinstance(evt, ActionEvent) and evt.action.kind == "warning"
|
||||
]
|
||||
|
||||
assert warnings
|
||||
assert events.index(warnings[0]) < events.index(completed)
|
||||
assert completed.ok is False
|
||||
assert completed.error == "Permission denied"
|
||||
assert completed.resume == started.resume
|
||||
|
||||
|
||||
def test_tool_results_pop_pending_actions() -> None:
|
||||
state = ClaudeStreamState()
|
||||
|
||||
tool_use_event = {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"id": "msg_1",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "Bash",
|
||||
"input": {"command": "echo hi"},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
tool_result_event = {
|
||||
"type": "user",
|
||||
"message": {
|
||||
"id": "msg_2",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_1",
|
||||
"content": "ok",
|
||||
"is_error": False,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
translate_claude_event(tool_use_event, title="claude", state=state)
|
||||
assert "toolu_1" in state.pending_actions
|
||||
|
||||
translate_claude_event(tool_result_event, title="claude", state=state)
|
||||
assert not state.pending_actions
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_serializes_same_session() -> None:
|
||||
runner = ClaudeRunner(claude_cmd="claude")
|
||||
gate = anyio.Event()
|
||||
in_flight = 0
|
||||
max_in_flight = 0
|
||||
|
||||
async def run_stub(*_args, **_kwargs):
|
||||
nonlocal in_flight, max_in_flight
|
||||
in_flight += 1
|
||||
max_in_flight = max(max_in_flight, in_flight)
|
||||
try:
|
||||
await gate.wait()
|
||||
yield CompletedEvent(
|
||||
engine=ENGINE,
|
||||
resume=ResumeToken(engine=ENGINE, value="sid"),
|
||||
ok=True,
|
||||
answer="ok",
|
||||
)
|
||||
finally:
|
||||
in_flight -= 1
|
||||
|
||||
runner._run = run_stub # type: ignore[assignment]
|
||||
|
||||
async def drain(prompt: str, resume: ResumeToken | None) -> None:
|
||||
async for _event in runner.run(prompt, resume):
|
||||
pass
|
||||
|
||||
token = ResumeToken(engine=ENGINE, value="sid")
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(drain, "a", token)
|
||||
tg.start_soon(drain, "b", token)
|
||||
await anyio.sleep(0)
|
||||
gate.set()
|
||||
assert max_in_flight == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_serializes_new_session_after_session_is_known(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
gate_path = tmp_path / "gate"
|
||||
resume_marker = tmp_path / "resume_started"
|
||||
session_id = "session_01"
|
||||
|
||||
claude_path = tmp_path / "claude"
|
||||
claude_path.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import json\n"
|
||||
"import os\n"
|
||||
"import sys\n"
|
||||
"import time\n"
|
||||
"\n"
|
||||
"gate = os.environ['CLAUDE_TEST_GATE']\n"
|
||||
"resume_marker = os.environ['CLAUDE_TEST_RESUME_MARKER']\n"
|
||||
"session_id = os.environ['CLAUDE_TEST_SESSION_ID']\n"
|
||||
"\n"
|
||||
"args = sys.argv[1:]\n"
|
||||
"if '--resume' in args or '-r' in args:\n"
|
||||
" print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n"
|
||||
" with open(resume_marker, 'w', encoding='utf-8') as f:\n"
|
||||
" f.write('started')\n"
|
||||
" f.flush()\n"
|
||||
" sys.exit(0)\n"
|
||||
"\n"
|
||||
"print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n"
|
||||
"while not os.path.exists(gate):\n"
|
||||
" time.sleep(0.001)\n"
|
||||
"sys.exit(0)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
claude_path.chmod(0o755)
|
||||
|
||||
monkeypatch.setenv("CLAUDE_TEST_GATE", str(gate_path))
|
||||
monkeypatch.setenv("CLAUDE_TEST_RESUME_MARKER", str(resume_marker))
|
||||
monkeypatch.setenv("CLAUDE_TEST_SESSION_ID", session_id)
|
||||
|
||||
runner = ClaudeRunner(claude_cmd=str(claude_path))
|
||||
|
||||
session_started = anyio.Event()
|
||||
resume_value: str | None = None
|
||||
new_done = anyio.Event()
|
||||
|
||||
async def run_new() -> None:
|
||||
nonlocal resume_value
|
||||
async for event in runner.run("hello", None):
|
||||
if isinstance(event, StartedEvent):
|
||||
resume_value = event.resume.value
|
||||
session_started.set()
|
||||
new_done.set()
|
||||
|
||||
async def run_resume() -> None:
|
||||
assert resume_value is not None
|
||||
async for _event in runner.run(
|
||||
"resume", ResumeToken(engine=ENGINE, value=resume_value)
|
||||
):
|
||||
pass
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(run_new)
|
||||
await session_started.wait()
|
||||
|
||||
tg.start_soon(run_resume)
|
||||
await anyio.sleep(0.01)
|
||||
|
||||
assert not resume_marker.exists()
|
||||
|
||||
gate_path.write_text("go", encoding="utf-8")
|
||||
await new_done.wait()
|
||||
|
||||
with anyio.fail_after(2):
|
||||
while not resume_marker.exists():
|
||||
await anyio.sleep(0.001)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_strips_anthropic_api_key_by_default(tmp_path, monkeypatch) -> None:
|
||||
claude_path = tmp_path / "claude"
|
||||
claude_path.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import json\n"
|
||||
"import os\n"
|
||||
"\n"
|
||||
"session_id = 'session_01'\n"
|
||||
"status = 'set' if os.environ.get('ANTHROPIC_API_KEY') else 'unset'\n"
|
||||
"print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n"
|
||||
"print(json.dumps({'type': 'result', 'subtype': 'success', 'is_error': False, 'result': f'api={status}', 'session_id': session_id}), flush=True)\n"
|
||||
"raise SystemExit(0)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
claude_path.chmod(0o755)
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "secret")
|
||||
|
||||
runner = ClaudeRunner(claude_cmd=str(claude_path))
|
||||
answer: str | None = None
|
||||
async for event in runner.run("hello", None):
|
||||
if isinstance(event, CompletedEvent):
|
||||
answer = event.answer
|
||||
assert answer == "api=unset"
|
||||
|
||||
runner_api = ClaudeRunner(claude_cmd=str(claude_path), use_api_billing=True)
|
||||
answer = None
|
||||
async for event in runner_api.run("hello", None):
|
||||
if isinstance(event, CompletedEvent):
|
||||
answer = event.answer
|
||||
assert answer == "api=set"
|
||||
@@ -624,25 +624,6 @@ def test_cancel_command_accepts_extra_text() -> None:
|
||||
assert _is_cancel_command("/cancelled") is False
|
||||
|
||||
|
||||
def test_resume_attempt_does_not_trigger_on_plain_resume_word() -> None:
|
||||
from takopi.bridge import _resume_attempt
|
||||
|
||||
attempt, engine = _resume_attempt("resume abc123")
|
||||
assert attempt is False
|
||||
assert engine is None
|
||||
|
||||
|
||||
def test_resume_warning_for_other_engine() -> None:
|
||||
from takopi.bridge import _resume_attempt, _resume_warning_text
|
||||
|
||||
attempt, engine = _resume_attempt("claude resume abc123")
|
||||
assert attempt is True
|
||||
assert engine == "claude"
|
||||
warning = _resume_warning_text(engine, "codex")
|
||||
assert "claude" in warning.lower()
|
||||
assert "codex" in warning.lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_handle_message_cancelled_renders_cancelled_state() -> None:
|
||||
from takopi.bridge import BridgeConfig, handle_message
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from takopi.utils.paths import relativize_command
|
||||
|
||||
|
||||
def test_relativize_command_rewrites_cwd_paths(tmp_path: Path) -> None:
|
||||
base = tmp_path / "repo"
|
||||
base.mkdir()
|
||||
command = f'find {base}/tests -type f -name "*.py" | head -20'
|
||||
expected = 'find tests -type f -name "*.py" | head -20'
|
||||
assert relativize_command(command, base_dir=base) == expected
|
||||
|
||||
|
||||
def test_relativize_command_rewrites_equals_paths(tmp_path: Path) -> None:
|
||||
base = tmp_path / "repo"
|
||||
base.mkdir()
|
||||
command = f'rg -n --files -g "*.py" --path={base}/src'
|
||||
expected = 'rg -n --files -g "*.py" --path=src'
|
||||
assert relativize_command(command, base_dir=base) == expected
|
||||
@@ -2,7 +2,7 @@ import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from takopi.runners import codex
|
||||
from takopi.utils import subprocess as subprocess_utils
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -13,9 +13,9 @@ async def test_manage_subprocess_kills_when_terminate_times_out(
|
||||
_ = timeout
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(codex, "_wait_for_process", fake_wait_for_process)
|
||||
monkeypatch.setattr(subprocess_utils, "wait_for_process", fake_wait_for_process)
|
||||
|
||||
async with codex.manage_subprocess(
|
||||
async with subprocess_utils.manage_subprocess(
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(10)",
|
||||
|
||||
Reference in New Issue
Block a user