diff --git a/.env.example b/.env.example index 0782e2a4..74db0734 100644 --- a/.env.example +++ b/.env.example @@ -166,6 +166,7 @@ LANGFUSE_TRACE_METADATA=ticket_key,ticket_type,project_id,workflow_step,repo,pr_ # joins the Langfuse compose network. # - The ClickHouse service is then reachable as clickhouse:9000. GRAFANA_PORT=3010 +GRAFANA_BASE_URL=http://localhost:3010 GRAFANA_ADMIN_USER=admin GRAFANA_ADMIN_PASSWORD=grafana LANGFUSE_DOCKER_NETWORK=langfuse_default diff --git a/README.md b/README.md index 35f7c4e3..1166c944 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,10 @@ Forge agents can access external tools via MCP (Model Context Protocol): Configure in `mcp-servers.json`. By default, MCP tools are read-only. +Forge also provides a separate read-only session-inspection API and optional MCP +server for users who want to inspect workflow progress without Redis, Langfuse, +or Grafana credentials. See [Session Inspection](docs/reference/session-inspection.md). + ## Project Structure ``` diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 8259cdb3..a74118bd 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -193,6 +193,7 @@ LANGFUSE_TRACE_METADATA=ticket_key,ticket_type,project_id,workflow_step,repo,pr_ # Grafana dashboard stack GRAFANA_PORT=3010 +GRAFANA_BASE_URL=http://localhost:3010 LANGFUSE_DOCKER_NETWORK=langfuse_default CLICKHOUSE_HOST=clickhouse CLICKHOUSE_PORT=9000 diff --git a/docs/index.md b/docs/index.md index b03712b7..132c3f12 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,7 @@ graph TD - [Feature Workflow](guide/feature-workflow.md) — How features flow through Forge - [Developer Guide](developer-guide.md) — Full local development reference - [Skills System](skills/index.md) — Customize Forge for your stack +- [Session Inspection](reference/session-inspection.md) — Check workflow progress safely - [Contributing](dev/contributing.md) — How to contribute ## Key Features diff --git a/docs/reference/api.md b/docs/reference/api.md index cd1983ed..391a1ddb 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -94,6 +94,73 @@ Exposes Prometheus-format metrics for the API server. Worker metrics are available separately at `http://localhost:8001/metrics`. +--- + +### Session Summary + +```http +GET /api/v1/sessions/{ticket_key}/summary +``` + +Returns a safe, read-only summary of a Forge workflow session for a Jira ticket. +This endpoint is intended for users who want to inspect session progress without +direct Redis, Langfuse, or Grafana access. + +The response is curated and intentionally excludes raw prompts, model messages, +generated artifacts, tool inputs, and full trace metadata. + +**Query parameters:** + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `logs_limit` | `0` | Optional number of Redis log entries to include. Allowed range: `0` to `50`. Keep this at `0` for the safest user-facing summary. | + +**Example:** + +```bash +curl http://localhost:8000/api/v1/sessions/AISOS-123/summary +``` + +**Response:** + +```json +{ + "summary": { + "ticket_key": "AISOS-123", + "found": true, + "current_node": "implement_task", + "status": "running", + "is_paused": false, + "is_blocked": false, + "retry_count": 0, + "last_error": null, + "ticket_type": "Feature", + "repository": "org/repo", + "pr_number": 42, + "pr_url": "https://github.com/org/repo/pull/42", + "ci_status": "pending", + "artifacts_present": { + "prd": true, + "spec": true, + "rca": false, + "plan": true, + "epics": true, + "tasks": true, + "qa_history": false + }, + "observability_links": { + "grafana_issue_detail": "http://localhost:3010/d/forge-issue-detail/forge-issue-detail?orgId=1&var-jira_issue=AISOS-123" + }, + "raw_state_exposed": false + }, + "notes": [ + "This summary is read-only and excludes raw prompts, model messages, generated artifacts, and tool inputs." + ] +} +``` + +Returns `404` if Forge has no persisted session state for the ticket. + ## Webhook Configuration ### Jira diff --git a/docs/reference/config.md b/docs/reference/config.md index 72f94b5d..9444ac89 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -112,6 +112,7 @@ These variables are used by `docker-compose.yml`, `devtools/docker-compose.dev.y | Variable | Description | |----------|-------------| | `GRAFANA_PORT` | Host port for Grafana (default: `3010`) | +| `GRAFANA_BASE_URL` | Public/base URL for Grafana, used by Forge session summaries and MCP responses to include dashboard links | | `GRAFANA_ADMIN_USER` | Grafana admin user (default: `admin`) | | `GRAFANA_ADMIN_PASSWORD` | Grafana admin password (default: `grafana`) | | `LANGFUSE_DOCKER_NETWORK` | External Docker/Podman network for self-hosted Langfuse when using `devtools/grafana/compose.langfuse-network.yml` (default: `langfuse_default`) | @@ -127,4 +128,9 @@ These variables are used by `docker-compose.yml`, `devtools/docker-compose.dev.y ### MCP Servers -MCP server configuration lives in `mcp-servers.json`, not `.env`. See the [MCP servers section](https://github.com/forge-sdlc/forge/blob/main/mcp-servers.json) of the repository. +MCP server configuration for Forge agents lives in `mcp-servers.json`, not `.env`. +The checked-in file is loaded by Forge agents and should only include external +tooling those agents need. + +The user-facing Forge session MCP server is configured separately in the user's +assistant client. See [Session Inspection](session-inspection.md). diff --git a/docs/reference/session-inspection.md b/docs/reference/session-inspection.md new file mode 100644 index 00000000..9ae2ca71 --- /dev/null +++ b/docs/reference/session-inspection.md @@ -0,0 +1,79 @@ +# Session Inspection + +Forge exposes a safe session-inspection surface so users can understand what is +happening in their workflow without needing Redis, Langfuse, or Grafana +credentials. + +The session summary is deliberately curated. It does not expose raw prompts, +model messages, generated artifacts, tool inputs, or full trace metadata. + +## HTTP API + +Use the Forge API when you want a direct integration or a quick command-line +check: + +```bash +curl http://localhost:8000/api/v1/sessions/AISOS-123/summary +``` + +The endpoint returns workflow progress, current status, PR information, CI +status, artifact presence, and observability links when configured. + +See the [API reference](api.md#session-summary) for the full response shape. + +## Optional Claude MCP Setup + +Forge also ships a read-only stdio MCP server named `forge-session-mcp`. This is +for user assistants such as Claude Desktop or Claude Code. It should be added to +the user's assistant configuration, not to Forge's `mcp-servers.json`. + +Do not add `forge-session-mcp` to the checked-in `mcp-servers.json`: that file is +loaded by Forge agents themselves, and the session-inspection server is meant for +external user inspection. + +### Claude Code + +From the Forge repository: + +```bash +uv sync +claude mcp add-json "forge-session" \ + '{"type":"stdio","command":"uv","args":["run","forge-session-mcp"],"cwd":"'"$(pwd)"'"}' +``` + +Then ask Claude for a session summary by Jira ticket key, for example: + +```text +Show me the Forge session summary for AISOS-123. +``` + +### Manual MCP JSON + +If your MCP client accepts JSON configuration, add this server entry and adjust +`cwd` to the local Forge repository path: + +```json +{ + "forge-session": { + "type": "stdio", + "command": "uv", + "args": ["run", "forge-session-mcp"], + "cwd": "/path/to/forge" + } +} +``` + +The server reads the same `.env` configuration as Forge, so it must be run from a +working Forge checkout with access to the configured Redis/checkpoint backend. + +## Available MCP Capability + +The MCP server provides: + +| Capability | Description | +|------------|-------------| +| `get_session_summary` | Tool that returns a safe summary for a Jira ticket key | +| `forge://sessions/{ticket_key}` | Resource URI that returns the same summary as JSON | + +The MCP response includes `raw_state_exposed: false` to make the redaction +contract explicit. diff --git a/pyproject.toml b/pyproject.toml index 2c0875a8..e8c3b67f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dev = [ [project.scripts] forge = "forge.cli:main" forge-serve = "forge.main:main" +forge-session-mcp = "forge.mcp.session:main" [tool.hatch.build.targets.wheel] packages = ["src/forge"] diff --git a/src/forge/api/routes/__init__.py b/src/forge/api/routes/__init__.py index 91f9d458..cb4b5da4 100644 --- a/src/forge/api/routes/__init__.py +++ b/src/forge/api/routes/__init__.py @@ -4,10 +4,12 @@ from forge.api.routes.health import router as health_router from forge.api.routes.jira import router as jira_router from forge.api.routes.metrics import router as metrics_router +from forge.api.routes.sessions import router as sessions_router __all__ = [ "github_router", "health_router", "jira_router", "metrics_router", + "sessions_router", ] diff --git a/src/forge/api/routes/sessions.py b/src/forge/api/routes/sessions.py new file mode 100644 index 00000000..ab662833 --- /dev/null +++ b/src/forge/api/routes/sessions.py @@ -0,0 +1,42 @@ +"""Read-only session inspection endpoints.""" + +from fastapi import APIRouter, HTTPException, Query, status + +from forge.sessions.models import SessionSummaryPayload +from forge.sessions.summary import SessionNotFoundError, get_session_summary + +router = APIRouter(prefix="/api/v1/sessions", tags=["sessions"]) + + +@router.get( + "/{ticket_key}/summary", + response_model=SessionSummaryPayload, + responses={ + 200: {"description": "Safe session summary"}, + 404: {"description": "Session not found"}, + }, +) +async def session_summary( + ticket_key: str, + logs_limit: int = Query( + default=0, + ge=0, + le=50, + description=( + "Optional number of Redis log entries to include. Defaults to 0 so " + "the public endpoint exposes checkpoint-derived summary only." + ), + ), +) -> SessionSummaryPayload: + """Return a safe read-only summary for a Forge session. + + This endpoint lets users inspect their session through Forge API without + direct Redis, Langfuse, or Grafana credentials. + """ + try: + return await get_session_summary(ticket_key, logs_limit=logs_limit) + except SessionNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No Forge session found for {ticket_key.strip().upper()}", + ) diff --git a/src/forge/config.py b/src/forge/config.py index bcb2a93f..75438ef4 100644 --- a/src/forge/config.py +++ b/src/forge/config.py @@ -217,6 +217,10 @@ def detect_model_provider(model_name: str) -> str: default="", description="Comma-separated list of TracingField names to include as Langfuse trace metadata", ) + grafana_base_url: str = Field( + default="", + description="Base URL for Forge Grafana dashboards, used for session observability links", + ) # Claude Agent SDK Configuration agent_enable_tools: bool = Field( diff --git a/src/forge/main.py b/src/forge/main.py index 0826a7fa..c9d5d087 100644 --- a/src/forge/main.py +++ b/src/forge/main.py @@ -11,7 +11,13 @@ from forge import __version__ from forge.api.middleware.correlation import CorrelationIdMiddleware -from forge.api.routes import github_router, health_router, jira_router, metrics_router +from forge.api.routes import ( + github_router, + health_router, + jira_router, + metrics_router, + sessions_router, +) from forge.config import get_settings from forge.observability.config import configure_tracing, shutdown_tracing from forge.orchestrator.checkpointer import close_redis_pool @@ -105,6 +111,10 @@ def create_app() -> FastAPI: "name": "github", "description": "GitHub webhook endpoints", }, + { + "name": "sessions", + "description": "Read-only Forge session inspection endpoints", + }, ], docs_url="/docs", redoc_url="/redoc", @@ -128,6 +138,7 @@ def create_app() -> FastAPI: app.include_router(metrics_router) app.include_router(jira_router) app.include_router(github_router) + app.include_router(sessions_router) return app diff --git a/src/forge/mcp/__init__.py b/src/forge/mcp/__init__.py new file mode 100644 index 00000000..caf203ca --- /dev/null +++ b/src/forge/mcp/__init__.py @@ -0,0 +1 @@ +"""MCP servers provided by Forge.""" diff --git a/src/forge/mcp/session.py b/src/forge/mcp/session.py new file mode 100644 index 00000000..d76399dd --- /dev/null +++ b/src/forge/mcp/session.py @@ -0,0 +1,67 @@ +"""Read-only MCP server for Forge session inspection.""" + +from __future__ import annotations + +import json +import logging + +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP + +from forge.sessions.summary import SessionNotFoundError, get_session_summary + +logger = logging.getLogger(__name__) + + +def create_server() -> FastMCP: + """Create the Forge session MCP server.""" + mcp = FastMCP( + "Forge Session", + instructions=( + "Read-only Forge session inspection. This server exposes curated " + "workflow summaries and intentionally omits raw prompts, model " + "messages, generated artifacts, and tool inputs." + ), + ) + + @mcp.tool( + name="get_session_summary", + description="Return a safe read-only summary for a Forge session by Jira ticket key.", + ) + async def get_session_summary_tool(ticket_key: str) -> dict: + try: + payload = await get_session_summary(ticket_key, logs_limit=0) + return payload.as_dict() + except SessionNotFoundError as exc: + return { + "summary": { + "ticket_key": ticket_key.strip().upper(), + "found": False, + "status": "not_found", + "raw_state_exposed": False, + }, + "notes": [str(exc)], + } + + @mcp.resource( + "forge://sessions/{ticket_key}", + name="Forge Session Summary", + description="Safe JSON summary for a Forge session.", + mime_type="application/json", + ) + async def session_summary_resource(ticket_key: str) -> str: + result = await get_session_summary_tool(ticket_key) + return json.dumps(result, indent=2, sort_keys=True) + + return mcp + + +def main() -> None: + """Run the Forge session MCP server over stdio.""" + load_dotenv() + logging.basicConfig(level=logging.INFO) + create_server().run("stdio") + + +if __name__ == "__main__": + main() diff --git a/src/forge/sessions/__init__.py b/src/forge/sessions/__init__.py new file mode 100644 index 00000000..a406a16e --- /dev/null +++ b/src/forge/sessions/__init__.py @@ -0,0 +1,5 @@ +"""Read-only session inspection helpers.""" + +from forge.sessions.summary import SessionNotFoundError, build_session_summary, get_session_summary + +__all__ = ["SessionNotFoundError", "build_session_summary", "get_session_summary"] diff --git a/src/forge/sessions/models.py b/src/forge/sessions/models.py new file mode 100644 index 00000000..ed15cb99 --- /dev/null +++ b/src/forge/sessions/models.py @@ -0,0 +1,50 @@ +"""Models for safe, read-only Forge session inspection.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class SessionSummary(BaseModel): + """Curated session state safe to expose through user-facing inspection tools.""" + + ticket_key: str + found: bool = True + current_node: str | None = None + status: str + is_paused: bool = False + is_blocked: bool = False + retry_count: int = 0 + last_error: str | None = None + ticket_type: str | None = None + created_at: str | None = None + updated_at: str | None = None + repository: str | None = None + pr_number: int | None = None + pr_url: str | None = None + pr_urls: list[str] = Field(default_factory=list) + ci_status: str | None = None + ci_fix_attempts: int = 0 + failed_check_names: list[str] = Field(default_factory=list) + ai_review_status: str | None = None + human_review_status: str | None = None + pr_merged: bool = False + current_task_key: str | None = None + implemented_tasks: list[str] = Field(default_factory=list) + repos_to_process: list[str] = Field(default_factory=list) + repos_completed: list[str] = Field(default_factory=list) + artifacts_present: dict[str, bool] = Field(default_factory=dict) + recent_events: list[str] = Field(default_factory=list) + observability_links: dict[str, str] = Field(default_factory=dict) + raw_state_exposed: bool = False + + +class SessionSummaryPayload(BaseModel): + """MCP response wrapper with a stable metadata surface.""" + + summary: SessionSummary + notes: list[str] = Field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + """Return a JSON-serializable response.""" + return self.model_dump(mode="json") diff --git a/src/forge/sessions/summary.py b/src/forge/sessions/summary.py new file mode 100644 index 00000000..11827642 --- /dev/null +++ b/src/forge/sessions/summary.py @@ -0,0 +1,181 @@ +"""Safe session summaries built from Forge checkpoint and Redis state.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any +from urllib.parse import quote + +from forge.config import get_settings +from forge.orchestrator.checkpointer import get_checkpoint_state, get_redis_client +from forge.sessions.models import SessionSummary, SessionSummaryPayload + + +class SessionNotFoundError(LookupError): + """Raised when no persisted session state exists for a ticket.""" + + +def _normalize_ticket_key(ticket_key: str) -> str: + normalized = ticket_key.strip().upper() + if not normalized: + raise ValueError("ticket_key must not be empty") + return normalized + + +def _stringify(value: Any) -> str | None: + if value is None: + return None + if hasattr(value, "value"): + return str(value.value) + return str(value) + + +def _as_str_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + result: list[str] = [] + for item in value: + text = _stringify(item) + if text: + result.append(text) + return result + + +def _failed_check_names(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + names: list[str] = [] + for item in value: + if isinstance(item, dict): + name = item.get("name") or item.get("check_name") or item.get("context") + if name: + names.append(str(name)) + elif item: + names.append(str(item)) + return names + + +def _artifact_presence(state: dict[str, Any]) -> dict[str, bool]: + return { + "prd": bool(state.get("prd_content")), + "spec": bool(state.get("spec_content")), + "rca": bool(state.get("rca_content")), + "plan": bool(state.get("plan_content")), + "epics": bool(state.get("epic_keys")), + "tasks": bool(state.get("task_keys")), + "qa_history": bool(state.get("qa_history")), + } + + +def _derive_status(state: dict[str, Any]) -> str: + if state.get("last_error"): + return "error" + if state.get("is_blocked"): + return "blocked" + if state.get("is_paused"): + return "waiting_for_input" + if state.get("pr_merged") or state.get("feature_completed") or state.get("bug_fix_implemented"): + return "completed" + return "running" + + +def _recent_events(logs: Iterable[Any]) -> list[str]: + events: list[str] = [] + for entry in logs: + if isinstance(entry, bytes): + events.append(entry.decode(errors="replace")) + elif entry is not None: + events.append(str(entry)) + return events + + +def _observability_links(ticket_key: str) -> dict[str, str]: + settings = get_settings() + links: dict[str, str] = {} + + if settings.langfuse_host: + links["langfuse"] = settings.langfuse_host.rstrip("/") + + if settings.grafana_base_url: + base = settings.grafana_base_url.rstrip("/") + encoded_ticket = quote(ticket_key, safe="") + links["grafana_issue_detail"] = ( + f"{base}/d/forge-issue-detail/forge-issue-detail?" + f"orgId=1&var-jira_issue={encoded_ticket}" + ) + links["grafana_engineering"] = ( + f"{base}/d/forge-engineering/forge-engineering-dashboard?" + f"orgId=1&var-jira_issue={encoded_ticket}" + ) + + return links + + +def build_session_summary( + ticket_key: str, + state: dict[str, Any] | None, + logs: Iterable[Any] = (), +) -> SessionSummaryPayload: + """Build a redacted session summary from persisted workflow state. + + Raw prompts, model messages, generated artifacts, tool inputs, and full trace + metadata are intentionally not included in the result. + """ + normalized_ticket = _normalize_ticket_key(ticket_key) + if state is None: + raise SessionNotFoundError(f"No Forge session found for {normalized_ticket}") + + pr_urls = _as_str_list(state.get("pr_urls")) + current_pr_url = _stringify(state.get("current_pr_url")) + if current_pr_url and current_pr_url not in pr_urls: + pr_urls = [current_pr_url, *pr_urls] + + summary = SessionSummary( + ticket_key=normalized_ticket, + current_node=_stringify(state.get("current_node")), + status=_derive_status(state), + is_paused=bool(state.get("is_paused", False)), + is_blocked=bool(state.get("is_blocked", False)), + retry_count=int(state.get("retry_count") or 0), + last_error=_stringify(state.get("last_error")), + ticket_type=_stringify(state.get("ticket_type")), + created_at=_stringify(state.get("created_at")), + updated_at=_stringify(state.get("updated_at")), + repository=_stringify(state.get("current_repo")), + pr_number=state.get("current_pr_number"), + pr_url=current_pr_url, + pr_urls=pr_urls, + ci_status=_stringify(state.get("ci_status")), + ci_fix_attempts=int(state.get("ci_fix_attempts") or 0), + failed_check_names=_failed_check_names(state.get("ci_failed_checks")), + ai_review_status=_stringify(state.get("ai_review_status")), + human_review_status=_stringify(state.get("human_review_status")), + pr_merged=bool(state.get("pr_merged", False)), + current_task_key=_stringify(state.get("current_task_key")), + implemented_tasks=_as_str_list(state.get("implemented_tasks")), + repos_to_process=_as_str_list(state.get("repos_to_process")), + repos_completed=_as_str_list(state.get("repos_completed")), + artifacts_present=_artifact_presence(state), + recent_events=_recent_events(logs), + observability_links=_observability_links(normalized_ticket), + ) + return SessionSummaryPayload( + summary=summary, + notes=[ + "This summary is read-only and excludes raw prompts, model messages, generated artifacts, and tool inputs." + ], + ) + + +async def get_session_summary(ticket_key: str, logs_limit: int = 0) -> SessionSummaryPayload: + """Fetch and summarize a Forge session by Jira ticket key.""" + normalized_ticket = _normalize_ticket_key(ticket_key) + state = await get_checkpoint_state(normalized_ticket) + if state is None: + raise SessionNotFoundError(f"No Forge session found for {normalized_ticket}") + + logs: list[Any] = [] + if logs_limit > 0: + redis_client = await get_redis_client() + logs = await redis_client.lrange(f"forge:logs:{normalized_ticket}", 0, logs_limit - 1) + return build_session_summary(normalized_ticket, state, logs) diff --git a/tests/unit/api/routes/test_sessions.py b/tests/unit/api/routes/test_sessions.py new file mode 100644 index 00000000..716298b8 --- /dev/null +++ b/tests/unit/api/routes/test_sessions.py @@ -0,0 +1,76 @@ +"""Tests for session inspection endpoints.""" + +from unittest.mock import AsyncMock + +import pytest +from httpx import ASGITransport, AsyncClient + +from forge.main import app +from forge.sessions.models import SessionSummary, SessionSummaryPayload +from forge.sessions.summary import SessionNotFoundError + + +@pytest.mark.asyncio +async def test_session_summary_returns_safe_payload(monkeypatch: pytest.MonkeyPatch) -> None: + get_session_summary = AsyncMock( + return_value=SessionSummaryPayload( + summary=SessionSummary( + ticket_key="TEST-123", + current_node="implementation", + status="running", + raw_state_exposed=False, + ), + notes=["safe"], + ) + ) + monkeypatch.setattr("forge.api.routes.sessions.get_session_summary", get_session_summary) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/sessions/test-123/summary") + + assert response.status_code == 200 + data = response.json() + assert data["summary"]["ticket_key"] == "TEST-123" + assert data["summary"]["current_node"] == "implementation" + assert data["summary"]["raw_state_exposed"] is False + get_session_summary.assert_awaited_once_with("test-123", logs_limit=0) + + +@pytest.mark.asyncio +async def test_session_summary_passes_bounded_logs_limit(monkeypatch: pytest.MonkeyPatch) -> None: + get_session_summary = AsyncMock( + return_value=SessionSummaryPayload( + summary=SessionSummary(ticket_key="TEST-123", status="running") + ) + ) + monkeypatch.setattr("forge.api.routes.sessions.get_session_summary", get_session_summary) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/sessions/TEST-123/summary?logs_limit=3") + + assert response.status_code == 200 + get_session_summary.assert_awaited_once_with("TEST-123", logs_limit=3) + + +@pytest.mark.asyncio +async def test_session_summary_returns_404_for_missing_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def raise_not_found(_ticket_key: str, **_kwargs: object) -> None: + raise SessionNotFoundError("missing") + + monkeypatch.setattr("forge.api.routes.sessions.get_session_summary", raise_not_found) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/sessions/test-404/summary") + + assert response.status_code == 404 + assert response.json()["detail"] == "No Forge session found for TEST-404" + + +@pytest.mark.asyncio +async def test_session_summary_rejects_large_logs_limit() -> None: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/sessions/TEST-123/summary?logs_limit=51") + + assert response.status_code == 422 diff --git a/tests/unit/mcp/test_session_server.py b/tests/unit/mcp/test_session_server.py new file mode 100644 index 00000000..4ec81da6 --- /dev/null +++ b/tests/unit/mcp/test_session_server.py @@ -0,0 +1,12 @@ +"""Tests for the Forge session MCP server wiring.""" + +from mcp.server.fastmcp import FastMCP + +from forge.mcp.session import create_server + + +def test_create_server_returns_fastmcp_instance() -> None: + server = create_server() + + assert isinstance(server, FastMCP) + assert server.name == "Forge Session" diff --git a/tests/unit/sessions/test_summary.py b/tests/unit/sessions/test_summary.py new file mode 100644 index 00000000..049a1e57 --- /dev/null +++ b/tests/unit/sessions/test_summary.py @@ -0,0 +1,119 @@ +"""Tests for safe Forge session summaries.""" + +from unittest.mock import AsyncMock + +import pytest + +from forge.models.workflow import TicketType +from forge.sessions.summary import ( + SessionNotFoundError, + build_session_summary, + get_session_summary, +) + + +def test_build_session_summary_excludes_raw_prompt_and_artifact_data() -> None: + state = { + "ticket_key": "TEST-123", + "ticket_type": TicketType.FEATURE, + "current_node": "ci_evaluator", + "is_paused": False, + "retry_count": 2, + "created_at": "2026-06-18T10:00:00", + "updated_at": "2026-06-18T10:05:00", + "current_repo": "org/repo", + "current_pr_number": 42, + "current_pr_url": "https://github.com/org/repo/pull/42", + "ci_status": "failed", + "ci_fix_attempts": 1, + "ci_failed_checks": [{"name": "unit-tests", "details_url": "https://ci.example"}], + "ai_review_status": "passed", + "human_review_status": "pending", + "implemented_tasks": ["TEST-124"], + "repos_to_process": ["org/repo"], + "repos_completed": [], + "prd_content": "raw PRD content", + "spec_content": "raw spec content", + "messages": [{"role": "user", "content": "SECRET_PROMPT_VALUE"}], + "context": {"trace_metadata": {"secret": "do-not-leak"}}, + "generation_context": {"prompt": "SECRET_GENERATION_CONTEXT"}, + "feedback_comment": "raw feedback", + } + + payload = build_session_summary("test-123", state, logs=["Started", b"CI failed"]) + result = payload.as_dict() + + assert result["summary"]["ticket_key"] == "TEST-123" + assert result["summary"]["status"] == "running" + assert result["summary"]["failed_check_names"] == ["unit-tests"] + assert result["summary"]["artifacts_present"]["prd"] is True + assert result["summary"]["artifacts_present"]["spec"] is True + assert result["summary"]["recent_events"] == ["Started", "CI failed"] + assert result["summary"]["raw_state_exposed"] is False + + serialized = str(result) + assert "raw PRD content" not in serialized + assert "raw spec content" not in serialized + assert "SECRET_PROMPT_VALUE" not in serialized + assert "SECRET_GENERATION_CONTEXT" not in serialized + assert "do-not-leak" not in serialized + assert "raw feedback" not in serialized + + +def test_build_session_summary_derives_waiting_status() -> None: + payload = build_session_summary( + "TEST-123", + { + "ticket_key": "TEST-123", + "current_node": "prd_approval_gate", + "is_paused": True, + }, + ) + + assert payload.summary.status == "waiting_for_input" + + +def test_build_session_summary_raises_for_missing_state() -> None: + with pytest.raises(SessionNotFoundError): + build_session_summary("TEST-123", None) + + +@pytest.mark.asyncio +async def test_get_session_summary_reads_checkpoint_and_logs(monkeypatch: pytest.MonkeyPatch) -> None: + redis = AsyncMock() + redis.lrange = AsyncMock(return_value=["latest event"]) + + async def fake_get_checkpoint_state(ticket_key: str) -> dict: + assert ticket_key == "TEST-123" + return { + "ticket_key": "TEST-123", + "current_node": "implementation", + "current_task_key": "TEST-124", + } + + monkeypatch.setattr("forge.sessions.summary.get_checkpoint_state", fake_get_checkpoint_state) + monkeypatch.setattr("forge.sessions.summary.get_redis_client", AsyncMock(return_value=redis)) + + payload = await get_session_summary("test-123", logs_limit=3) + + assert payload.summary.ticket_key == "TEST-123" + assert payload.summary.current_node == "implementation" + assert payload.summary.current_task_key == "TEST-124" + redis.lrange.assert_awaited_once_with("forge:logs:TEST-123", 0, 2) + + +@pytest.mark.asyncio +async def test_get_session_summary_skips_redis_logs_when_limit_is_zero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_get_checkpoint_state(_ticket_key: str) -> dict: + return {"ticket_key": "TEST-123", "current_node": "implementation"} + + get_redis_client = AsyncMock() + monkeypatch.setattr("forge.sessions.summary.get_checkpoint_state", fake_get_checkpoint_state) + monkeypatch.setattr("forge.sessions.summary.get_redis_client", get_redis_client) + + payload = await get_session_summary("TEST-123", logs_limit=0) + + assert payload.summary.recent_events == [] + get_redis_client.assert_not_called()