feat(mvp): Phase 1 control layer + API vertical slice

Implements the Phase 1 MVP from the README: a stateless control layer + HTTP
API over a centralized database, with hook-enforced test/push gates.

- DB layer: SQLAlchemy Core, one schema rendering both Postgres (BIGSERIAL /
  TIMESTAMPTZ / JSONB) and SQLite (INTEGER PK / TEXT / JSON) via portable types;
  native ON CONFLICT DO UPDATE checkmark upsert on both dialects.
- Alembic dual-dialect migrations (render_as_batch for SQLite); tests run a real
  `alembic upgrade head`.
- FastAPI: projects/agents/checkmark/log/answer/resume + shared log/context
  routes, single global bearer token, higher-trust token gating shared-context
  writes, project isolation on every route.
- Hooks (`python -m handler.hooks <event>`): Stop test gate (block on red),
  PreToolUse AskUserQuestion defer + `git push` gate (tests then throwaway
  build), Notification generic webhook (no-op without WEBHOOK_URL). Identity via
  env injected at spawn; verify is the mock seam.
- Control CLI: spawn/list/attach/kill, hard `.mise.toml [tasks.test]` gate,
  generated per-agent settings.json, identity + DATABASE_URL injected via tmux;
  tmux is the mock seam.
- 45 tests (SQLite), ruff clean. Live claude/tmux/mise spawning deferred behind
  the mocked seams.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5ZuS5pV1NS6eKsRZHXonY
This commit is contained in:
Claude
2026-07-07 18:16:54 +00:00
parent 2fafc91c0e
commit eba0e19ec9
51 changed files with 2982 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
"""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 three seams
(``control.tmux``, ``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)
_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": []}
live: set[str] = set()
from handler.control import tmux
def new_session(name, cwd, command, env):
calls["new_session"].append(
{"name": name, "cwd": cwd, "command": command, "env": env}
)
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 list_sessions():
return list(live)
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, "list_sessions", list_sessions)
return {"calls": calls, "live": live}
+20
View File
@@ -0,0 +1,20 @@
"""Bearer auth on every route."""
from __future__ import annotations
def test_missing_token_is_401(client):
assert client.get("/projects").status_code == 401
def test_wrong_token_is_401(client):
r = client.get("/projects", headers={"Authorization": "Bearer nope"})
assert r.status_code == 401
def test_valid_token_is_200(client, auth):
assert client.get("/projects", headers=auth).status_code == 200
def test_health_needs_no_auth(client):
assert client.get("/health").status_code == 200
+76
View File
@@ -0,0 +1,76 @@
"""Answer + resume routes, including the mocked control seam."""
from __future__ import annotations
from handler.control import spawn
from handler.db import repository as repo
from handler.db.engine import get_engine
def _seed_agent_with_question(env):
"""Seed a project + agent + an open question directly in the DB."""
with get_engine().begin() as conn:
repo.create_project(conn, "proj", "/tmp/proj")
a = repo.create_agent(conn, "proj", "api", "/tmp/proj/api", status="paused_for_input")
log_id = repo.insert_log_entry(
conn, a["id"], status="paused_for_input", question="Which DB?"
)
return a, log_id
def test_answer_backfills_latest_open_question(client, auth, env):
_seed_agent_with_question(env)
r = client.post(
"/projects/proj/agents/api/answer",
json={"answer": "Postgres"},
headers=auth,
)
assert r.status_code == 200
assert r.json()["answered"] is True
with get_engine().begin() as conn:
a = repo.get_agent_by_name(conn, "proj", "api")
assert repo.get_log(conn, a["id"])[0]["answer"] == "Postgres"
def test_answer_with_no_open_question_is_404(client, auth, env):
with get_engine().begin() as conn:
repo.create_project(conn, "proj", "/tmp/proj")
repo.create_agent(conn, "proj", "api", "/tmp/proj/api")
r = client.post(
"/projects/proj/agents/api/answer", json={"answer": "x"}, headers=auth
)
assert r.status_code == 404
def test_resume_calls_control_seam(client, auth, env, monkeypatch):
_seed_agent_with_question(env)
client.post(
"/projects/proj/agents/api/answer", json={"answer": "Postgres"}, headers=auth
)
calls = []
def fake_resume(agent, answer):
calls.append((agent["name"], answer))
return True, "delivered"
monkeypatch.setattr(spawn, "resume", fake_resume)
r = client.post("/projects/proj/agents/api/resume", json={}, headers=auth)
assert r.status_code == 200
assert r.json()["resumed"] is True
assert calls == [("api", "Postgres")]
with get_engine().begin() as conn:
a = repo.get_agent_by_name(conn, "proj", "api")
assert a["status"] == "working"
def test_resume_without_answer_is_400(client, auth, env, monkeypatch):
with get_engine().begin() as conn:
repo.create_project(conn, "proj", "/tmp/proj")
repo.create_agent(conn, "proj", "api", "/tmp/proj/api")
monkeypatch.setattr(spawn, "resume", lambda a, ans: (True, "x"))
r = client.post("/projects/proj/agents/api/resume", json={}, headers=auth)
assert r.status_code == 400
+64
View File
@@ -0,0 +1,64 @@
"""Project + agent routes, and project isolation (README 3.4)."""
from __future__ import annotations
def _mk_project(client, auth, pid="proj", root="/tmp/proj"):
return client.post("/projects", json={"id": pid, "root_dir": root}, headers=auth)
def test_create_and_list_project(client, auth):
r = _mk_project(client, auth)
assert r.status_code == 201
assert r.json()["id"] == "proj"
listing = client.get("/projects", headers=auth).json()
assert [p["id"] for p in listing] == ["proj"]
def test_duplicate_project_conflicts(client, auth):
_mk_project(client, auth)
assert _mk_project(client, auth).status_code == 409
def test_create_and_list_agent(client, auth):
_mk_project(client, auth)
r = client.post(
"/projects/proj/agents",
json={"name": "api", "working_dir": "/tmp/proj/api"},
headers=auth,
)
assert r.status_code == 201
agents = client.get("/projects/proj/agents", headers=auth).json()
assert [a["name"] for a in agents] == ["api"]
def test_agent_under_missing_project_is_404(client, auth):
r = client.get("/projects/ghost/agents", headers=auth)
assert r.status_code == 404
def test_project_isolation_same_agent_name(client, auth):
# Two projects can each have an agent named "api"; neither leaks into the other.
_mk_project(client, auth, "a", "/tmp/a")
_mk_project(client, auth, "b", "/tmp/b")
client.post(
"/projects/a/agents",
json={"name": "api", "working_dir": "/tmp/a/api"},
headers=auth,
)
a_agents = client.get("/projects/a/agents", headers=auth).json()
b_agents = client.get("/projects/b/agents", headers=auth).json()
assert [x["name"] for x in a_agents] == ["api"]
assert b_agents == []
# The agent is invisible under project b.
assert client.get("/projects/b/agents/api/checkmark", headers=auth).status_code == 404
def test_checkmark_404_before_any_checkpoint(client, auth):
_mk_project(client, auth)
client.post(
"/projects/proj/agents",
json={"name": "api", "working_dir": "/tmp/proj/api"},
headers=auth,
)
assert client.get("/projects/proj/agents/api/checkmark", headers=auth).status_code == 404
+40
View File
@@ -0,0 +1,40 @@
"""Shared-context + shared-log endpoints and the write-token gate."""
from __future__ import annotations
from handler.db import repository as repo
from handler.db.engine import get_engine
def test_put_shared_context_requires_write_token(client, auth, env):
# The normal token is not enough to write shared context.
r = client.put("/shared/context/schema_version", json={"value": "v3"}, headers=auth)
assert r.status_code == 403
write_headers = {"Authorization": f"Bearer {env['shared_token']}"}
r = client.put(
"/shared/context/schema_version", json={"value": "v3"}, headers=write_headers
)
assert r.status_code == 200
assert r.json()["value"] == "v3"
def test_read_shared_context_uses_normal_token(client, auth, env):
write_headers = {"Authorization": f"Bearer {env['shared_token']}"}
client.put("/shared/context/k", json={"value": "v"}, headers=write_headers)
assert client.get("/shared/context", headers=auth).status_code == 200
assert client.get("/shared/context/k", headers=auth).json()["value"] == "v"
assert client.get("/shared/context/missing", headers=auth).status_code == 404
def test_shared_log_returns_only_global(client, auth, env):
with get_engine().begin() as conn:
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
repo.insert_log_entry(conn, a["id"], status="working", summary="private")
repo.insert_log_entry(
conn, a["id"], status="working", summary="global-note", visibility="global"
)
entries = client.get("/shared/log", headers=auth).json()
assert [e["summary"] for e in entries] == ["global-note"]
+91
View File
@@ -0,0 +1,91 @@
"""Control-layer spawn: the hard test-task gate, settings generation, identity env."""
from __future__ import annotations
import json
import pytest
from handler.control import spawn
from handler.db import repository as repo
from handler.db.engine import get_engine
def _register_project(root):
with get_engine().begin() as conn:
repo.create_project(conn, "proj", str(root))
def _write_mise(root, with_test=True):
root.mkdir(parents=True, exist_ok=True)
body = "[tasks.lint]\nrun = 'ruff check .'\n"
if with_test:
body = "[tasks.test]\nrun = 'pytest'\n" + body
(root / ".mise.toml").write_text(body)
def test_spawn_refuses_without_test_task(env, fake_tmux):
root = env["tmp"] / "proj"
_write_mise(root, with_test=False)
_register_project(root)
with pytest.raises(spawn.SpawnError, match="no \\[tasks.test\\]"):
spawn.spawn("proj", "api")
assert fake_tmux["calls"]["new_session"] == []
def test_spawn_refuses_without_mise_file(env, fake_tmux):
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
_register_project(root)
with pytest.raises(spawn.SpawnError, match="no .mise.toml"):
spawn.spawn("proj", "api")
def test_spawn_creates_agent_settings_and_session(env, fake_tmux):
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
_register_project(root)
agent = spawn.spawn("proj", "api", task="build the thing")
# Agent row created.
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
# settings.json wires all four hook events.
settings = json.loads((root / ".claude" / "settings.json").read_text())
assert set(settings["hooks"]) == {"Stop", "SessionEnd", "PreToolUse", "Notification"}
pre = settings["hooks"]["PreToolUse"][0]
assert pre["matcher"] == "AskUserQuestion|Bash"
assert "handler.hooks pre_tool_use" in pre["hooks"][0]["command"]
# tmux session named project__agent, with identity + DATABASE_URL in env.
call = fake_tmux["calls"]["new_session"][0]
assert call["name"] == "proj__api"
assert call["env"]["HANDLER_PROJECT_ID"] == "proj"
assert call["env"]["HANDLER_AGENT_NAME"] == "api"
assert call["env"]["HANDLER_AGENT_ID"] == str(agent["id"])
assert call["env"]["DATABASE_URL"] == env["url"]
def test_kill_sets_done_and_kills_session(env, fake_tmux):
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
_register_project(root)
spawn.spawn("proj", "api")
spawn.kill("proj", "api")
assert "proj__api" in fake_tmux["calls"]["kill_session"]
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "api")["status"] == "done"
def test_resume_sends_answer_to_live_session(env, fake_tmux):
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
_register_project(root)
agent = spawn.spawn("proj", "api")
ok, detail = spawn.resume(agent, "use Postgres")
assert ok is True
assert fake_tmux["calls"]["send_keys"][0] == {"name": "proj__api", "keys": "use Postgres"}
+39
View File
@@ -0,0 +1,39 @@
"""Portable types round-trip correctly on SQLite (aware datetimes, JSON lists)."""
from __future__ import annotations
from datetime import UTC, datetime
from handler.db import repository as repo
def _seed_agent(conn):
repo.create_project(conn, "p", "/tmp/p")
return repo.create_agent(conn, "p", "a", "/tmp/p/a")
def test_timestamp_roundtrips_as_utc_aware(conn):
agent = _seed_agent(conn)
ts = datetime(2026, 7, 7, 12, 30, tzinfo=UTC)
repo.upsert_checkmark_row(conn, agent["id"], checkpoint_at=ts, status="working")
row = repo.get_checkmark(conn, agent["id"])
assert row["checkpoint_at"] == ts
assert row["checkpoint_at"].tzinfo is not None
def test_naive_timestamp_is_normalized_to_utc(conn):
agent = _seed_agent(conn)
naive = datetime(2026, 7, 7, 12, 30) # no tzinfo
repo.upsert_checkmark_row(conn, agent["id"], checkpoint_at=naive, status="working")
row = repo.get_checkmark(conn, agent["id"])
assert row["checkpoint_at"] == naive.replace(tzinfo=UTC)
def test_json_list_roundtrips(conn):
agent = _seed_agent(conn)
steps = ["write tests", "wire the poller", "document the token flow"]
repo.upsert_checkmark_row(
conn, agent["id"], status="working", next_steps=steps
)
row = repo.get_checkmark(conn, agent["id"])
assert row["next_steps"] == steps
+52
View File
@@ -0,0 +1,52 @@
"""The highest-value DB test: the checkmark upsert overwrites in place (ON CONFLICT DO
UPDATE), keeping a single row with preserved identity — never delete+reinsert.
"""
from __future__ import annotations
from sqlalchemy import func, select
from handler.db import repository as repo
from handler.db.tables import checkmarks
def _seed_agent(conn):
repo.create_project(conn, "p", "/tmp/p")
return repo.create_agent(conn, "p", "a", "/tmp/p/a")
def test_upsert_overwrites_single_row(conn):
agent = _seed_agent(conn)
repo.upsert_checkmark_row(
conn,
agent["id"],
status="working",
where_it_stopped="first stop",
tests_status="unknown",
)
repo.upsert_checkmark_row(
conn,
agent["id"],
status="done",
where_it_stopped="second stop",
tests_status="pass",
)
count = conn.execute(select(func.count()).select_from(checkmarks)).scalar_one()
assert count == 1
row = repo.get_checkmark(conn, agent["id"])
assert row["status"] == "done"
assert row["where_it_stopped"] == "second stop"
assert row["tests_status"] == "pass"
assert row["agent_id"] == agent["id"]
def test_upsert_only_touches_supplied_columns_via_defaults(conn):
agent = _seed_agent(conn)
repo.upsert_checkmark_row(conn, agent["id"], status="working")
row = repo.get_checkmark(conn, agent["id"])
# Unsupplied gate columns fall back to their schema defaults.
assert row["tests_status"] == "unknown"
assert row["build_status"] == "unknown"
+65
View File
@@ -0,0 +1,65 @@
"""Stop / SessionEnd checkpoint gate."""
from __future__ import annotations
from handler.db import repository as repo
from handler.hooks import checkpoint, verify
from handler.hooks.context import HookInput, Identity
def _seed(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a")
def test_stop_blocks_on_failing_tests(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "1 failed"))
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result["decision"] == "block"
assert "test gate failed" in result["reason"]
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["tests_status"] == "fail"
assert cm["status"] == "blocked"
# A blocked turn never records "done".
assert repo.get_agent_by_name(conn, "p", "a")["status"] == "blocked"
def test_stop_allows_done_on_passing_tests(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result == {} # no block
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["tests_status"] == "pass"
assert cm["status"] == "done"
assert cm["log_entry_id"] is not None
def test_stop_does_not_reblock_when_already_active(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "still failing"))
hi = HookInput({"session_id": "s1", "stop_hook_active": True}, "stop")
result = checkpoint.handle_stop(conn, ident, hi)
assert result == {} # recorded, but not an infinite block
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "fail"
def test_session_end_records_without_gate(conn, monkeypatch):
ident = _seed(conn)
# Even if tests would fail, SessionEnd must not run the gate or block.
monkeypatch.setattr(
verify, "run_test", lambda cwd: (_ for _ in ()).throw(AssertionError("gate ran"))
)
result = checkpoint.handle_session_end(
conn, ident, HookInput({"reason": "clear"}, "session_end")
)
assert result == {}
assert repo.get_checkmark(conn, ident.agent_id)["where_it_stopped"].startswith(
"session ended"
)
+41
View File
@@ -0,0 +1,41 @@
"""The `python -m handler.hooks <event>` dispatch: stdin parsing + identity from env."""
from __future__ import annotations
import io
from handler.db import repository as repo
from handler.db.engine import get_engine
from handler.hooks import __main__ as hook_main
from handler.hooks import verify
def _seed(env):
with get_engine().begin() as conn:
repo.create_project(conn, "p", "/tmp/p")
return repo.create_agent(conn, "p", "a", "/tmp/p/a")
def test_dispatch_stop_via_stdin_and_env(env, monkeypatch, capsys):
agent = _seed(env)
monkeypatch.setenv("HANDLER_AGENT_ID", str(agent["id"]))
monkeypatch.setenv("HANDLER_PROJECT_ID", "p")
monkeypatch.setenv("HANDLER_AGENT_NAME", "a")
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
monkeypatch.setattr("sys.stdin", io.StringIO('{"session_id": "s1"}'))
rc = hook_main.main(["stop"])
assert rc == 0
with get_engine().begin() as conn:
assert repo.get_checkmark(conn, agent["id"])["tests_status"] == "pass"
def test_dispatch_unknown_event_is_usage_error(env):
assert hook_main.main(["frobnicate"]) == 2
def test_dispatch_unresolvable_identity_returns_1(env, monkeypatch):
monkeypatch.delenv("HANDLER_AGENT_ID", raising=False)
monkeypatch.setattr("sys.stdin", io.StringIO('{"cwd": "/nowhere"}'))
assert hook_main.main(["stop"]) == 1
+79
View File
@@ -0,0 +1,79 @@
"""PreToolUse: AskUserQuestion defer + git-push gate."""
from __future__ import annotations
from handler.db import repository as repo
from handler.hooks import gate, verify
from handler.hooks.context import HookInput, Identity
def _seed(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a")
def _decision(result):
return result["hookSpecificOutput"]["permissionDecision"]
def test_ask_user_question_is_deferred(conn):
ident = _seed(conn)
hi = HookInput(
{
"tool_name": "AskUserQuestion",
"tool_input": {"questions": [{"question": "Which DB?"}]},
"session_id": "s1",
},
"pre_tool_use",
)
result = gate.handle_ask_user_question(conn, ident, hi)
assert _decision(result) == "deny"
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["status"] == "paused_for_input"
assert cm["open_question"] == "Which DB?"
assert repo.get_latest_open_question(conn, ident.agent_id)["question"] == "Which DB?"
def test_git_push_denied_when_tests_fail(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "1 failed"))
# Build must not even run when tests fail (cheap check first).
monkeypatch.setattr(
verify, "run_build", lambda cwd: (_ for _ in ()).throw(AssertionError("built"))
)
hi = HookInput(
{"tool_name": "Bash", "tool_input": {"command": "git push origin main"}},
"pre_tool_use",
)
result = gate.handle_git_push(conn, ident, hi)
assert _decision(result) == "deny"
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "fail"
def test_git_push_denied_when_build_fails(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
monkeypatch.setattr(verify, "run_build", lambda cwd: (False, "COPY failed"))
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "git push"}}, "pre_tool_use")
result = gate.handle_git_push(conn, ident, hi)
assert _decision(result) == "deny"
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["tests_status"] == "pass"
assert cm["build_status"] == "fail"
def test_git_push_allowed_when_both_pass(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
monkeypatch.setattr(verify, "run_build", lambda cwd: (True, "built"))
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "git push"}}, "pre_tool_use")
result = gate.handle_git_push(conn, ident, hi)
assert _decision(result) == "allow"
def test_non_push_bash_is_ignored(conn):
ident = _seed(conn)
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "ls -la"}}, "pre_tool_use")
assert gate.handle(conn, ident, hi) == {}
+46
View File
@@ -0,0 +1,46 @@
"""Notification hook: webhook POST only when WEBHOOK_URL is set; log always written."""
from __future__ import annotations
import httpx
import respx
from handler.db import repository as repo
from handler.hooks import notify
from handler.hooks.context import HookInput, Identity
def _seed(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a")
def test_notify_noop_without_webhook(conn, env):
ident = _seed(conn)
hi = HookInput({"message": "needs input", "session_id": "s1"}, "notification")
# WEBHOOK_URL is unset in the env fixture -> no HTTP call, but the log is recorded.
notify.handle(conn, ident, hi)
assert "notification: needs input" in repo.get_log(conn, ident.agent_id)[0]["summary"]
@respx.mock
def test_notify_posts_when_webhook_set(conn, env, monkeypatch):
monkeypatch.setenv("WEBHOOK_URL", "https://ntfy.example/topic")
from handler import config
config.get_settings.cache_clear()
route = respx.post("https://ntfy.example/topic").mock(return_value=httpx.Response(200))
ident = _seed(conn)
hi = HookInput({"message": "hello", "session_id": "s1"}, "notification")
notify.handle(conn, ident, hi)
assert route.called
sent = route.calls[0].request
import json
body = json.loads(sent.content)
assert body["project"] == "p"
assert body["agent"] == "a"
assert body["message"] == "hello"
+54
View File
@@ -0,0 +1,54 @@
"""DAL read/write functions and the answer backfill."""
from __future__ import annotations
from handler.db import repository as repo
def test_project_and_agent_crud(conn):
repo.create_project(conn, "proj", "/tmp/proj", git_remote="git@x:proj.git")
assert repo.get_project(conn, "proj")["root_dir"] == "/tmp/proj"
assert [p["id"] for p in repo.list_projects(conn)] == ["proj"]
a = repo.create_agent(conn, "proj", "api", "/tmp/proj/api")
assert a["status"] == "working"
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == a["id"]
assert repo.get_agent_by_name(conn, "proj", "missing") is None
def test_log_insert_and_answer_backfill(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
log_id = repo.insert_log_entry(
conn, a["id"], status="paused_for_input", question="Which DB?"
)
open_q = repo.get_latest_open_question(conn, a["id"])
assert open_q["id"] == log_id
assert repo.update_log_answer(conn, log_id, "Postgres") is True
# Once answered, it is no longer an open question.
assert repo.get_latest_open_question(conn, a["id"]) is None
assert repo.get_log(conn, a["id"])[0]["answer"] == "Postgres"
def test_shared_context_upsert(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
repo.set_shared_context(conn, "staging_url", "https://a", a["id"])
assert repo.get_shared_context_key(conn, "staging_url")["value"] == "https://a"
repo.set_shared_context(conn, "staging_url", "https://b", a["id"])
assert repo.get_shared_context_key(conn, "staging_url")["value"] == "https://b"
assert len(repo.get_shared_context(conn)) == 1
def test_shared_log_only_global(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
repo.insert_log_entry(conn, a["id"], status="working", summary="private")
repo.insert_log_entry(
conn, a["id"], status="working", summary="shared", visibility="global"
)
shared = repo.get_shared_log(conn)
assert [e["summary"] for e in shared] == ["shared"]