Skip to content
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@ When reviewing code, provide constructive feedback:

</DEV_SETUP>


<GH_API_NOTES>
## Replying to GitHub inline review threads (PR review comments)

- There is no working `.../pulls/comments/{comment_id}/replies` endpoint for PR review comments.
- To reply in an existing inline thread, use the REST API:
- List comments (incl. inline threads):
- `GET /repos/{owner}/{repo}/pulls/{pull_number}/comments?per_page=100`
- Top-level inline comments have `in_reply_to_id = null`.
- Replies have `in_reply_to_id = <top_level_comment_id>`.
- Post a threaded reply:
- `POST /repos/{owner}/{repo}/pulls/{pull_number}/comments`
- body: `{ "body": "...", "in_reply_to": <comment_id> }`

This creates a proper reply attached to the original inline comment thread.
</GH_API_NOTES>

<PR_ARTIFACTS>
# PR-Specific Documents

Expand Down
2 changes: 2 additions & 0 deletions openhands-agent-server/openhands/agent_server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from openhands.agent_server.event_router import event_router
from openhands.agent_server.file_router import file_router
from openhands.agent_server.git_router import git_router
from openhands.agent_server.hooks_router import hooks_router
from openhands.agent_server.middleware import LocalhostCORSMiddleware
from openhands.agent_server.server_details_router import (
get_server_info,
Expand Down Expand Up @@ -175,6 +176,7 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
api_router.include_router(vscode_router)
api_router.include_router(desktop_router)
api_router.include_router(skills_router)
api_router.include_router(hooks_router)
app.include_router(api_router)
app.include_router(sockets_router)

Expand Down
76 changes: 76 additions & 0 deletions openhands-agent-server/openhands/agent_server/hooks_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Hooks router for OpenHands Agent Server.

This module defines the HTTP API endpoints for hook operations.
Business logic is delegated to hooks_service.py.
"""

from pathlib import Path

from fastapi import APIRouter
from pydantic import BaseModel, Field

from openhands.agent_server.hooks_service import load_hooks
from openhands.sdk.hooks import HookConfig


hooks_router = APIRouter(prefix="/hooks", tags=["Hooks"])


def _validate_project_dir(project_dir: str) -> str | None:
expanded = Path(project_dir).expanduser()
if not expanded.is_absolute():
return None

try:
resolved = expanded.resolve(strict=False)
except Exception:
return None

return str(resolved)


class HooksRequest(BaseModel):
"""Request body for loading hooks."""

load_project: bool = Field(
default=True,
description=(
"Whether to load project hooks from {project_dir}/.openhands/hooks.json"
),
)
load_user: bool = Field(
default=False,
description="Whether to load user hooks from ~/.openhands/hooks.json",
)
project_dir: str | None = Field(
default=None, description="Workspace directory path for project hooks"
)


class HooksResponse(BaseModel):
"""Response containing hooks configuration."""

hook_config: HookConfig | None = Field(
default=None,
description=(
"Hook configuration loaded from the workspace, or None if not found"
),
)


@hooks_router.post("", response_model=HooksResponse)
def get_hooks(request: HooksRequest) -> HooksResponse:
"""Load hooks from the workspace .openhands/hooks.json file."""

project_dir = None
if request.project_dir is not None:
project_dir = _validate_project_dir(request.project_dir)
if project_dir is None:
return HooksResponse(hook_config=None)

hook_config = load_hooks(
load_project=request.load_project,
load_user=request.load_user,
project_dir=project_dir,
)
return HooksResponse(hook_config=hook_config)
53 changes: 53 additions & 0 deletions openhands-agent-server/openhands/agent_server/hooks_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Hooks service for OpenHands Agent Server.

This module contains the business logic for loading hooks from project and user
locations.

Sources:
- Project hooks: {project_dir}/.openhands/hooks.json
- User hooks: ~/.openhands/hooks.json

The agent-server does not own policy; it only respects request flags.
"""

from __future__ import annotations

from openhands.sdk.hooks import HookConfig, load_project_hooks, load_user_hooks
from openhands.sdk.logger import get_logger


logger = get_logger(__name__)


def load_hooks(
*,
load_project: bool,
load_user: bool,
project_dir: str | None,
) -> HookConfig | None:
hook_configs: list[HookConfig] = []

def _safe_load(loader, *args, log_message: str):
try:
return loader(*args)
except Exception:
logger.exception(log_message)
return None

if load_project and project_dir:
project_hooks = _safe_load(
load_project_hooks,
project_dir,
log_message="Failed to load project hooks",
)
if project_hooks is not None:
hook_configs.append(project_hooks)

if load_user:
user_hooks = _safe_load(
load_user_hooks, log_message="Failed to load user hooks"
)
if user_hooks is not None:
hook_configs.append(user_hooks)

return HookConfig.merge(hook_configs) if hook_configs else None
126 changes: 126 additions & 0 deletions tests/agent_server/test_hooks_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from pathlib import Path

from fastapi.testclient import TestClient

from openhands.agent_server.api import create_app
from openhands.agent_server.config import Config


def test_hooks_endpoint_returns_none_when_not_found(tmp_path):
app = create_app(Config(session_api_keys=[]))
client = TestClient(app)

resp = client.post(
"/api/hooks",
json={"load_project": True, "load_user": False, "project_dir": str(tmp_path)},
)
assert resp.status_code == 200
data = resp.json()
assert data["hook_config"] is None


def test_hooks_endpoint_returns_hook_config_when_present(tmp_path):
hooks_dir = tmp_path / ".openhands"
hooks_dir.mkdir(parents=True)
hooks_file = hooks_dir / "hooks.json"
hooks_file.write_text(
'{"session_start":[{"matcher":"*","hooks":[{"command":"echo hi"}]}]}'
)

app = create_app(Config(session_api_keys=[]))
client = TestClient(app)

resp = client.post(
"/api/hooks",
json={"load_project": True, "load_user": False, "project_dir": str(tmp_path)},
)
assert resp.status_code == 200
data = resp.json()
assert data["hook_config"] is not None
assert data["hook_config"]["session_start"][0]["hooks"][0]["command"] == "echo hi"


def test_hooks_endpoint_respects_load_project_false(tmp_path):
hooks_dir = tmp_path / ".openhands"
hooks_dir.mkdir(parents=True)
(hooks_dir / "hooks.json").write_text(
'{"session_start":[{"matcher":"*","hooks":[{"command":"echo hi"}]}]}'
)

app = create_app(Config(session_api_keys=[]))
client = TestClient(app)

resp = client.post(
"/api/hooks",
json={"load_project": False, "load_user": False, "project_dir": str(tmp_path)},
)
assert resp.status_code == 200
assert resp.json()["hook_config"] is None


def test_hooks_endpoint_accepts_relative_project_dir_and_returns_none(tmp_path):
app = create_app(Config(session_api_keys=[]))
client = TestClient(app)

resp = client.post(
"/api/hooks",
json={
"load_project": True,
"load_user": False,
"project_dir": "relative/path",
},
)

assert resp.status_code == 200
assert resp.json()["hook_config"] is None


def test_hooks_endpoint_returns_none_on_malformed_project_hooks_json(tmp_path):
hooks_dir = tmp_path / ".openhands"
hooks_dir.mkdir(parents=True)
(hooks_dir / "hooks.json").write_text("not json")

app = create_app(Config(session_api_keys=[]))
client = TestClient(app)

resp = client.post(
"/api/hooks",
json={"load_project": True, "load_user": False, "project_dir": str(tmp_path)},
)

assert resp.status_code == 200
assert resp.json()["hook_config"] is None


def test_hooks_endpoint_merges_project_and_user_hooks(tmp_path, monkeypatch):
app = create_app(Config(session_api_keys=[]))
client = TestClient(app)

hooks_dir = tmp_path / ".openhands"
hooks_dir.mkdir(parents=True)
(hooks_dir / "hooks.json").write_text(
'{"session_start":[{"matcher":"*","hooks":[{"command":"echo project"}]}]}'
)

fake_home = tmp_path / "fake_home"
(fake_home / ".openhands").mkdir(parents=True)
(fake_home / ".openhands" / "hooks.json").write_text(
'{"session_start":[{"matcher":"*","hooks":[{"command":"echo user"}]}]}'
)

monkeypatch.setattr(Path, "home", lambda: fake_home)

resp = client.post(
"/api/hooks",
json={"load_project": True, "load_user": True, "project_dir": str(tmp_path)},
)

assert resp.status_code == 200
hook_config = resp.json()["hook_config"]
assert hook_config is not None

session_start = hook_config["session_start"]
commands = [
hook["command"] for matcher in session_start for hook in matcher["hooks"]
]
assert commands == ["echo project", "echo user"]
Loading