mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 10:16:24 +00:00
a3a5c272a2
Model backend rows gain a harness column (claude | pi). A pi-harness row runs the agent through the pi coding agent instead of the claude binary — pi speaks the OpenAI Completions API natively, so a bare vLLM/llama.cpp/Ollama endpoint needs no LiteLLM/claude-code-router translation proxy, and the loop is far lighter for slow local token throughput. The Claude subscription and existing claude-harness backends are untouched. Parity comes from generated per-agent artifacts under ~/.handler-pi (outside the repo tree, so the clean-tree gate never trips): models.json + settings.json render the row as a pi provider pinned as the default model; a bundled bridge extension (pi_bridge.ts) adapts pi's events to the exact stdin/stdout contract of `python -m handler.hooks` — the Stop/completion gate re-prompts pi with blockers via a follow-up message, git push runs the test/build/approval gates and denies on failure, questions defer through an ask_operator tool into the normal answer/resume flow, and memory recall is injected at session start. The memory tools are registered natively (pi has no MCP), shelling to a new `python -m handler.mcpserver --call <tool>` seam that reuses the MCP server's implementations. Skills reuse the same ~/.claude/skills sync (pi implements the same SKILL.md standard) plus the repo's committed .claude/skills. Sessions are single JSONL files pre-assigned via --session, so cross-worker resume archives/materializes exactly like claude's; the prompt travels on stdin (pi has no -- separator). The supervisor normalizes pi's event stream on the fly: assistant message_end feeds last_output, the final agent_end becomes the run result. The whole chain was validated live against pi 0.84.1 with a stub OpenAI endpoint: memory injection, push-gate denial (including the protected- branch approval gate), stop-gate block loop, and ask_operator pause all ran end to end through the real hooks and DB. Also: harness selector in the dashboard Models form, pi baked into the control image (NodeSource 22 for pi's node >= 22.19 floor), PI_BIN override, docs in docs/local-models.md, fake_pi fixture + 12 tests (361 total green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KdGv3u3DfTsP1S188KDhVH
194 lines
6.2 KiB
Python
194 lines
6.2 KiB
Python
"""Shared fixtures. Everything runs on a fresh SQLite file per test, materialized via
|
|
a *real* ``alembic upgrade head`` — so the migration path itself is under test, not
|
|
just ``create_all``. No live claude/tmux/mise is ever touched: the seams
|
|
(``control.headless.launch`` for runs, ``control.tmux`` for the login flow,
|
|
``hooks.verify``, ``control.spawn.resume``) are faked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _reset_caches() -> None:
|
|
from handler import config
|
|
from handler.db import engine
|
|
|
|
config.get_settings.cache_clear()
|
|
engine.get_engine.cache_clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def env(tmp_path, monkeypatch):
|
|
"""Point every entrypoint at a fresh SQLite db + a known token, migrated."""
|
|
db_path = tmp_path / "handler.db"
|
|
url = f"sqlite:///{db_path}"
|
|
monkeypatch.setenv("DATABASE_URL", url)
|
|
monkeypatch.setenv("AUTH_TOKEN", "test-token")
|
|
monkeypatch.setenv("SHARED_CONTEXT_WRITE_TOKEN", "shared-token")
|
|
monkeypatch.setenv("PROJECTS_ROOT", str(tmp_path / "projects"))
|
|
monkeypatch.delenv("WEBHOOK_URL", raising=False)
|
|
# Isolate any home-dir writes (e.g. control.claude_config seeding ~/.claude.json at
|
|
# spawn) to the per-test tmp dir, so tests never touch the real user's config.
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
_reset_caches()
|
|
|
|
cfg = Config(str(REPO_ROOT / "alembic.ini"))
|
|
cfg.set_main_option("script_location", str(REPO_ROOT / "src" / "handler" / "migrations"))
|
|
command.upgrade(cfg, "head")
|
|
|
|
yield {"url": url, "token": "test-token", "shared_token": "shared-token", "tmp": tmp_path}
|
|
|
|
_reset_caches()
|
|
|
|
|
|
@pytest.fixture
|
|
def engine(env):
|
|
from handler.db.engine import get_engine
|
|
|
|
return get_engine()
|
|
|
|
|
|
@pytest.fixture
|
|
def conn(engine):
|
|
with engine.begin() as c:
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
def client(env):
|
|
from fastapi.testclient import TestClient
|
|
|
|
from handler.api.app import create_app
|
|
|
|
return TestClient(create_app())
|
|
|
|
|
|
@pytest.fixture
|
|
def auth(env):
|
|
return {"Authorization": f"Bearer {env['token']}"}
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_tmux(monkeypatch):
|
|
"""Record tmux calls instead of spawning; report sessions as live by default."""
|
|
calls: dict[str, list] = {
|
|
"new_session": [],
|
|
"kill_session": [],
|
|
"send_keys": [],
|
|
"send_text": [],
|
|
"send_enter": [],
|
|
}
|
|
live: set[str] = set()
|
|
|
|
from handler.control import tmux
|
|
|
|
def new_session(name, cwd, command, env, *, width=None, height=None):
|
|
calls["new_session"].append(
|
|
{"name": name, "cwd": cwd, "command": command, "env": env,
|
|
"width": width, "height": height}
|
|
)
|
|
live.add(name)
|
|
|
|
def has_session(name):
|
|
return name in live
|
|
|
|
def kill_session(name):
|
|
calls["kill_session"].append(name)
|
|
live.discard(name)
|
|
|
|
def send_keys(name, keys):
|
|
calls["send_keys"].append({"name": name, "keys": keys})
|
|
|
|
def send_text(name, text):
|
|
calls["send_text"].append({"name": name, "text": text})
|
|
|
|
def send_enter(name):
|
|
calls["send_enter"].append({"name": name})
|
|
|
|
monkeypatch.setattr(tmux, "new_session", new_session)
|
|
monkeypatch.setattr(tmux, "has_session", has_session)
|
|
monkeypatch.setattr(tmux, "kill_session", kill_session)
|
|
monkeypatch.setattr(tmux, "send_keys", send_keys)
|
|
monkeypatch.setattr(tmux, "send_text", send_text)
|
|
monkeypatch.setattr(tmux, "send_enter", send_enter)
|
|
|
|
return {"calls": calls, "live": live}
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_launch(monkeypatch):
|
|
"""Record ``headless.launch`` calls instead of spawning a claude subprocess.
|
|
|
|
Mirrors the real launch's DB side effects (run row + agent session/worker) so kill/
|
|
resume logic downstream of a fake spawn behaves like production, minus the process.
|
|
"""
|
|
from handler.control import headless
|
|
from handler.db import repository as repo
|
|
from handler.db.engine import connection
|
|
|
|
calls: list[dict] = []
|
|
|
|
def launch(agent, *, kind, prompt, settings_path, env, worker_id, on_exit=None,
|
|
harness="claude"):
|
|
session_id = agent.get("session_id") if kind == "resume" else f"fake-sid-{len(calls) + 1}"
|
|
with connection() as conn:
|
|
run = repo.create_run(conn, agent["id"], session_id, worker_id, kind)
|
|
repo.set_agent_session(conn, agent["id"], session_id, worker_id)
|
|
calls.append(
|
|
{"agent": agent, "kind": kind, "prompt": prompt, "settings_path": settings_path,
|
|
"env": env, "worker_id": worker_id, "run": run, "harness": harness}
|
|
)
|
|
return run
|
|
|
|
monkeypatch.setattr(headless, "launch", launch)
|
|
return calls
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_gitops(monkeypatch):
|
|
"""Fake the git seam: record config/add/commit, return a controllable branch/sha."""
|
|
from handler.control import gitops
|
|
|
|
state = {"branch": "feat/x", "sha": "abc123def456", "config": [], "add": [], "commit": []}
|
|
|
|
def config_local(cwd, key, value):
|
|
state["config"].append({"cwd": cwd, "key": key, "value": value})
|
|
return True, ""
|
|
|
|
def add(cwd, paths):
|
|
state["add"].append({"cwd": cwd, "paths": paths})
|
|
return True, ""
|
|
|
|
def commit(cwd, message):
|
|
state["commit"].append({"cwd": cwd, "message": message})
|
|
return True, ""
|
|
|
|
monkeypatch.setattr(gitops, "current_branch", lambda cwd: state["branch"])
|
|
monkeypatch.setattr(gitops, "head_sha", lambda cwd: state["sha"])
|
|
monkeypatch.setattr(gitops, "config_local", config_local)
|
|
monkeypatch.setattr(gitops, "add", add)
|
|
monkeypatch.setattr(gitops, "commit", commit)
|
|
return state
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_forge(monkeypatch):
|
|
"""Fake the forge seam: controllable version check + CI runs."""
|
|
from handler.control import forge
|
|
|
|
state = {"version_ok": True, "version_out": "forge 1.2.3", "ci_ok": True, "runs": []}
|
|
|
|
monkeypatch.setattr(
|
|
forge, "check_version", lambda cwd=".": (state["version_ok"], state["version_out"])
|
|
)
|
|
monkeypatch.setattr(forge, "ci_list", lambda cwd, sha: (state["ci_ok"], state["runs"]))
|
|
monkeypatch.setattr(forge, "ci_log", lambda cwd, run_id: (True, "log"))
|
|
return state
|