forked from Rockachopa/Timmy-time-dashboard
Wire orchestrator pipe into task runner + pipe-verifying integration tests (#134)
This commit is contained in:
committed by
GitHub
parent
d10cff333a
commit
87dc5eadfe
@@ -149,6 +149,23 @@ class Settings(BaseSettings):
|
||||
thinking_enabled: bool = True
|
||||
thinking_interval_seconds: int = 300 # 5 minutes between thoughts
|
||||
|
||||
# ── Paperclip AI — orchestration bridge ────────────────────────────
|
||||
# URL where the Paperclip server listens.
|
||||
# For VPS deployment behind nginx, use the public domain.
|
||||
paperclip_url: str = "http://localhost:3100"
|
||||
# Enable/disable the Paperclip integration.
|
||||
paperclip_enabled: bool = False
|
||||
# API key or auth-gate cookie for authenticating with Paperclip.
|
||||
paperclip_api_key: str = ""
|
||||
# Timmy's agent ID in the Paperclip org chart.
|
||||
paperclip_agent_id: str = ""
|
||||
# Company ID in Paperclip — required for most API calls.
|
||||
paperclip_company_id: str = ""
|
||||
# Timeout in seconds for Paperclip HTTP calls.
|
||||
paperclip_timeout: int = 30
|
||||
# How often (seconds) Timmy polls Paperclip for work (0 = disabled).
|
||||
paperclip_poll_interval: int = 0
|
||||
|
||||
# ── OpenFang — vendored agent runtime ─────────────────────────────
|
||||
# URL where the OpenFang sidecar listens. Set to the Docker service
|
||||
# name when running in compose, or localhost for bare-metal dev.
|
||||
|
||||
@@ -39,6 +39,7 @@ from dashboard.routes.thinking import router as thinking_router
|
||||
from dashboard.routes.calm import router as calm_router
|
||||
from dashboard.routes.swarm import router as swarm_router
|
||||
from dashboard.routes.system import router as system_router
|
||||
from dashboard.routes.paperclip import router as paperclip_router
|
||||
from infrastructure.router.api import router as cascade_router
|
||||
|
||||
# Import dedicated middleware
|
||||
@@ -304,6 +305,7 @@ app.include_router(thinking_router)
|
||||
app.include_router(calm_router)
|
||||
app.include_router(swarm_router)
|
||||
app.include_router(system_router)
|
||||
app.include_router(paperclip_router)
|
||||
app.include_router(cascade_router)
|
||||
|
||||
|
||||
|
||||
318
src/dashboard/routes/paperclip.py
Normal file
318
src/dashboard/routes/paperclip.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""Paperclip AI integration routes.
|
||||
|
||||
Timmy-as-CEO: create issues, delegate to agents, review work, manage goals.
|
||||
All business logic lives in the bridge — these routes stay thin.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/paperclip", tags=["paperclip"])
|
||||
|
||||
|
||||
def _disabled_response() -> JSONResponse:
|
||||
return JSONResponse({"enabled": False, "detail": "Paperclip integration is disabled"})
|
||||
|
||||
|
||||
# ── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def paperclip_status():
|
||||
"""Integration health check."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
status = await bridge.get_status()
|
||||
return status.model_dump()
|
||||
|
||||
|
||||
# ── Issues (CEO creates & manages tickets) ───────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/issues")
|
||||
async def list_issues(status: Optional[str] = None):
|
||||
"""List all issues in the company."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
issues = await bridge.client.list_issues(status=status)
|
||||
return [i.model_dump() for i in issues]
|
||||
|
||||
|
||||
@router.get("/issues/{issue_id}")
|
||||
async def get_issue(issue_id: str):
|
||||
"""Get issue details with comments (CEO review)."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
return await bridge.review_issue(issue_id)
|
||||
|
||||
|
||||
@router.post("/issues")
|
||||
async def create_issue(request: Request):
|
||||
"""Create a new issue and optionally assign to an agent."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
body = await request.json()
|
||||
title = body.get("title")
|
||||
if not title:
|
||||
return JSONResponse({"error": "title is required"}, status_code=400)
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
issue = await bridge.create_and_assign(
|
||||
title=title,
|
||||
description=body.get("description", ""),
|
||||
assignee_id=body.get("assignee_id"),
|
||||
priority=body.get("priority"),
|
||||
wake=body.get("wake", True),
|
||||
)
|
||||
|
||||
if not issue:
|
||||
return JSONResponse({"error": "Failed to create issue"}, status_code=502)
|
||||
|
||||
return issue.model_dump()
|
||||
|
||||
|
||||
@router.post("/issues/{issue_id}/delegate")
|
||||
async def delegate_issue(issue_id: str, request: Request):
|
||||
"""Delegate an issue to an agent (CEO assignment)."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
body = await request.json()
|
||||
agent_id = body.get("agent_id")
|
||||
if not agent_id:
|
||||
return JSONResponse({"error": "agent_id is required"}, status_code=400)
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
ok = await bridge.delegate_issue(
|
||||
issue_id=issue_id,
|
||||
agent_id=agent_id,
|
||||
message=body.get("message"),
|
||||
)
|
||||
|
||||
if not ok:
|
||||
return JSONResponse({"error": "Failed to delegate issue"}, status_code=502)
|
||||
|
||||
return {"ok": True, "issue_id": issue_id, "agent_id": agent_id}
|
||||
|
||||
|
||||
@router.post("/issues/{issue_id}/close")
|
||||
async def close_issue(issue_id: str, request: Request):
|
||||
"""Close an issue (CEO sign-off)."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
body = await request.json()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
ok = await bridge.close_issue(issue_id, comment=body.get("comment"))
|
||||
|
||||
if not ok:
|
||||
return JSONResponse({"error": "Failed to close issue"}, status_code=502)
|
||||
|
||||
return {"ok": True, "issue_id": issue_id}
|
||||
|
||||
|
||||
@router.post("/issues/{issue_id}/comment")
|
||||
async def add_comment(issue_id: str, request: Request):
|
||||
"""Add a CEO comment to an issue."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
body = await request.json()
|
||||
content = body.get("content")
|
||||
if not content:
|
||||
return JSONResponse({"error": "content is required"}, status_code=400)
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
comment = await bridge.client.add_comment(issue_id, f"[CEO] {content}")
|
||||
|
||||
if not comment:
|
||||
return JSONResponse({"error": "Failed to add comment"}, status_code=502)
|
||||
|
||||
return comment.model_dump()
|
||||
|
||||
|
||||
# ── Agents (team management) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def list_agents():
|
||||
"""List all agents in the org."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
agents = await bridge.get_team()
|
||||
return [a.model_dump() for a in agents]
|
||||
|
||||
|
||||
@router.get("/org")
|
||||
async def org_chart():
|
||||
"""Get the organizational chart."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
org = await bridge.get_org_chart()
|
||||
return org or {"error": "Could not retrieve org chart"}
|
||||
|
||||
|
||||
@router.post("/agents/{agent_id}/wake")
|
||||
async def wake_agent(agent_id: str, request: Request):
|
||||
"""Wake an agent to start working."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
body = await request.json()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
result = await bridge.client.wake_agent(
|
||||
agent_id,
|
||||
issue_id=body.get("issue_id"),
|
||||
message=body.get("message"),
|
||||
)
|
||||
|
||||
if not result:
|
||||
return JSONResponse({"error": "Failed to wake agent"}, status_code=502)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── Goals ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/goals")
|
||||
async def list_goals():
|
||||
"""List company goals."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
goals = await bridge.list_goals()
|
||||
return [g.model_dump() for g in goals]
|
||||
|
||||
|
||||
@router.post("/goals")
|
||||
async def create_goal(request: Request):
|
||||
"""Set a new company goal (CEO directive)."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
body = await request.json()
|
||||
title = body.get("title")
|
||||
if not title:
|
||||
return JSONResponse({"error": "title is required"}, status_code=400)
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
goal = await bridge.set_goal(title, body.get("description", ""))
|
||||
|
||||
if not goal:
|
||||
return JSONResponse({"error": "Failed to create goal"}, status_code=502)
|
||||
|
||||
return goal.model_dump()
|
||||
|
||||
|
||||
# ── Approvals ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/approvals")
|
||||
async def list_approvals():
|
||||
"""List pending approvals for CEO review."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
return await bridge.pending_approvals()
|
||||
|
||||
|
||||
@router.post("/approvals/{approval_id}/approve")
|
||||
async def approve(approval_id: str, request: Request):
|
||||
"""Approve an agent's action."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
body = await request.json()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
ok = await bridge.approve(approval_id, body.get("comment", ""))
|
||||
|
||||
if not ok:
|
||||
return JSONResponse({"error": "Failed to approve"}, status_code=502)
|
||||
|
||||
return {"ok": True, "approval_id": approval_id}
|
||||
|
||||
|
||||
@router.post("/approvals/{approval_id}/reject")
|
||||
async def reject(approval_id: str, request: Request):
|
||||
"""Reject an agent's action."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
body = await request.json()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
ok = await bridge.reject(approval_id, body.get("comment", ""))
|
||||
|
||||
if not ok:
|
||||
return JSONResponse({"error": "Failed to reject"}, status_code=502)
|
||||
|
||||
return {"ok": True, "approval_id": approval_id}
|
||||
|
||||
|
||||
# ── Runs (monitoring) ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def list_runs():
|
||||
"""List active heartbeat runs."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
return await bridge.active_runs()
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/cancel")
|
||||
async def cancel_run(run_id: str):
|
||||
"""Cancel a running heartbeat execution."""
|
||||
if not settings.paperclip_enabled:
|
||||
return _disabled_response()
|
||||
|
||||
from integrations.paperclip.bridge import bridge
|
||||
|
||||
ok = await bridge.cancel_run(run_id)
|
||||
|
||||
if not ok:
|
||||
return JSONResponse({"error": "Failed to cancel run"}, status_code=502)
|
||||
|
||||
return {"ok": True, "run_id": run_id}
|
||||
0
src/integrations/paperclip/__init__.py
Normal file
0
src/integrations/paperclip/__init__.py
Normal file
195
src/integrations/paperclip/bridge.py
Normal file
195
src/integrations/paperclip/bridge.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Paperclip bridge — CEO-level orchestration logic.
|
||||
|
||||
Timmy acts as the CEO: reviews issues, delegates to agents, tracks goals,
|
||||
and approves/rejects work. All business logic lives here; routes stay thin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import settings
|
||||
from integrations.paperclip.client import PaperclipClient, paperclip
|
||||
from integrations.paperclip.models import (
|
||||
CreateIssueRequest,
|
||||
PaperclipAgent,
|
||||
PaperclipGoal,
|
||||
PaperclipIssue,
|
||||
PaperclipStatusResponse,
|
||||
UpdateIssueRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaperclipBridge:
|
||||
"""Bidirectional bridge between Timmy and Paperclip.
|
||||
|
||||
Timmy is the CEO — he creates issues, delegates to agents via wakeup,
|
||||
reviews results, and manages the company's goals.
|
||||
"""
|
||||
|
||||
def __init__(self, client: Optional[PaperclipClient] = None):
|
||||
self.client = client or paperclip
|
||||
|
||||
# ── status / health ──────────────────────────────────────────────────
|
||||
|
||||
async def get_status(self) -> PaperclipStatusResponse:
|
||||
"""Return integration status for the dashboard."""
|
||||
if not settings.paperclip_enabled:
|
||||
return PaperclipStatusResponse(
|
||||
enabled=False,
|
||||
paperclip_url=settings.paperclip_url,
|
||||
)
|
||||
|
||||
connected = await self.client.healthy()
|
||||
agent_count = 0
|
||||
issue_count = 0
|
||||
error = None
|
||||
|
||||
if connected:
|
||||
try:
|
||||
agents = await self.client.list_agents()
|
||||
agent_count = len(agents)
|
||||
issues = await self.client.list_issues()
|
||||
issue_count = len(issues)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
else:
|
||||
error = "Cannot reach Paperclip server"
|
||||
|
||||
return PaperclipStatusResponse(
|
||||
enabled=True,
|
||||
connected=connected,
|
||||
paperclip_url=settings.paperclip_url,
|
||||
company_id=settings.paperclip_company_id,
|
||||
agent_count=agent_count,
|
||||
issue_count=issue_count,
|
||||
error=error,
|
||||
)
|
||||
|
||||
# ── CEO actions: issue management ────────────────────────────────────
|
||||
|
||||
async def create_and_assign(
|
||||
self,
|
||||
title: str,
|
||||
description: str = "",
|
||||
assignee_id: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
wake: bool = True,
|
||||
) -> Optional[PaperclipIssue]:
|
||||
"""Create an issue and optionally assign + wake an agent.
|
||||
|
||||
This is the primary CEO action: decide what needs doing, create
|
||||
the ticket, assign it to the right agent, and kick off execution.
|
||||
"""
|
||||
req = CreateIssueRequest(
|
||||
title=title,
|
||||
description=description,
|
||||
priority=priority,
|
||||
assignee_id=assignee_id,
|
||||
)
|
||||
issue = await self.client.create_issue(req)
|
||||
if not issue:
|
||||
logger.error("Failed to create issue: %s", title)
|
||||
return None
|
||||
|
||||
logger.info("Created issue %s: %s", issue.id, title)
|
||||
|
||||
if assignee_id and wake:
|
||||
result = await self.client.wake_agent(assignee_id, issue_id=issue.id)
|
||||
if result:
|
||||
logger.info("Woke agent %s for issue %s", assignee_id, issue.id)
|
||||
else:
|
||||
logger.warning("Failed to wake agent %s", assignee_id)
|
||||
|
||||
return issue
|
||||
|
||||
async def delegate_issue(
|
||||
self,
|
||||
issue_id: str,
|
||||
agent_id: str,
|
||||
message: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Assign an existing issue to an agent and wake them."""
|
||||
updated = await self.client.update_issue(
|
||||
issue_id,
|
||||
UpdateIssueRequest(assignee_id=agent_id),
|
||||
)
|
||||
if not updated:
|
||||
return False
|
||||
|
||||
if message:
|
||||
await self.client.add_comment(issue_id, f"[CEO] {message}")
|
||||
|
||||
await self.client.wake_agent(agent_id, issue_id=issue_id)
|
||||
return True
|
||||
|
||||
async def review_issue(
|
||||
self,
|
||||
issue_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Gather all context for CEO review of an issue."""
|
||||
issue = await self.client.get_issue(issue_id)
|
||||
comments = await self.client.list_comments(issue_id)
|
||||
|
||||
return {
|
||||
"issue": issue.model_dump() if issue else None,
|
||||
"comments": [c.model_dump() for c in comments],
|
||||
}
|
||||
|
||||
async def close_issue(self, issue_id: str, comment: Optional[str] = None) -> bool:
|
||||
"""Close an issue as the CEO."""
|
||||
if comment:
|
||||
await self.client.add_comment(issue_id, f"[CEO] {comment}")
|
||||
result = await self.client.update_issue(
|
||||
issue_id,
|
||||
UpdateIssueRequest(status="done"),
|
||||
)
|
||||
return result is not None
|
||||
|
||||
# ── CEO actions: team management ─────────────────────────────────────
|
||||
|
||||
async def get_team(self) -> List[PaperclipAgent]:
|
||||
"""Get the full agent roster."""
|
||||
return await self.client.list_agents()
|
||||
|
||||
async def get_org_chart(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get the organizational hierarchy."""
|
||||
return await self.client.get_org()
|
||||
|
||||
# ── CEO actions: goal management ─────────────────────────────────────
|
||||
|
||||
async def list_goals(self) -> List[PaperclipGoal]:
|
||||
return await self.client.list_goals()
|
||||
|
||||
async def set_goal(self, title: str, description: str = "") -> Optional[PaperclipGoal]:
|
||||
return await self.client.create_goal(title, description)
|
||||
|
||||
# ── CEO actions: approvals ───────────────────────────────────────────
|
||||
|
||||
async def pending_approvals(self) -> List[Dict[str, Any]]:
|
||||
return await self.client.list_approvals()
|
||||
|
||||
async def approve(self, approval_id: str, comment: str = "") -> bool:
|
||||
result = await self.client.approve(approval_id, comment)
|
||||
return result is not None
|
||||
|
||||
async def reject(self, approval_id: str, comment: str = "") -> bool:
|
||||
result = await self.client.reject(approval_id, comment)
|
||||
return result is not None
|
||||
|
||||
# ── CEO actions: monitoring ──────────────────────────────────────────
|
||||
|
||||
async def active_runs(self) -> List[Dict[str, Any]]:
|
||||
"""Get currently running heartbeat executions."""
|
||||
return await self.client.list_heartbeat_runs()
|
||||
|
||||
async def cancel_run(self, run_id: str) -> bool:
|
||||
result = await self.client.cancel_run(run_id)
|
||||
return result is not None
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
bridge = PaperclipBridge()
|
||||
308
src/integrations/paperclip/client.py
Normal file
308
src/integrations/paperclip/client.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""Paperclip AI API client.
|
||||
|
||||
Async HTTP client for communicating with a remote Paperclip server.
|
||||
All methods degrade gracefully — log the error, return a fallback, never crash.
|
||||
|
||||
Paperclip API is mounted at ``/api`` and uses ``local_trusted`` mode on the
|
||||
VPS, so the board actor is implicit. When the server sits behind an nginx
|
||||
auth-gate the client authenticates with Basic-auth on the first request and
|
||||
re-uses the session cookie thereafter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
from integrations.paperclip.models import (
|
||||
AddCommentRequest,
|
||||
CreateIssueRequest,
|
||||
PaperclipAgent,
|
||||
PaperclipComment,
|
||||
PaperclipGoal,
|
||||
PaperclipIssue,
|
||||
UpdateIssueRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaperclipClient:
|
||||
"""Thin async wrapper around the Paperclip REST API.
|
||||
|
||||
All public methods return typed results on success or ``None`` / ``[]``
|
||||
on failure so callers never need to handle exceptions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 30,
|
||||
):
|
||||
self._base_url = (base_url or settings.paperclip_url).rstrip("/")
|
||||
self._api_key = api_key or settings.paperclip_api_key
|
||||
self._timeout = timeout or settings.paperclip_timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
headers: Dict[str, str] = {"Accept": "application/json"}
|
||||
if self._api_key:
|
||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers=headers,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async def _get(self, path: str, params: Optional[Dict] = None) -> Optional[Any]:
|
||||
try:
|
||||
resp = await self._get_client().get(path, params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Paperclip GET %s failed: %s", path, exc)
|
||||
return None
|
||||
|
||||
async def _post(self, path: str, json: Optional[Dict] = None) -> Optional[Any]:
|
||||
try:
|
||||
resp = await self._get_client().post(path, json=json)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Paperclip POST %s failed: %s", path, exc)
|
||||
return None
|
||||
|
||||
async def _patch(self, path: str, json: Optional[Dict] = None) -> Optional[Any]:
|
||||
try:
|
||||
resp = await self._get_client().patch(path, json=json)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Paperclip PATCH %s failed: %s", path, exc)
|
||||
return None
|
||||
|
||||
async def _delete(self, path: str) -> bool:
|
||||
try:
|
||||
resp = await self._get_client().delete(path)
|
||||
resp.raise_for_status()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Paperclip DELETE %s failed: %s", path, exc)
|
||||
return False
|
||||
|
||||
# ── health ───────────────────────────────────────────────────────────
|
||||
|
||||
async def healthy(self) -> bool:
|
||||
"""Quick connectivity check."""
|
||||
data = await self._get("/api/health")
|
||||
return data is not None
|
||||
|
||||
# ── companies ────────────────────────────────────────────────────────
|
||||
|
||||
async def list_companies(self) -> List[Dict[str, Any]]:
|
||||
data = await self._get("/api/companies")
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
# ── agents ───────────────────────────────────────────────────────────
|
||||
|
||||
async def list_agents(self, company_id: Optional[str] = None) -> List[PaperclipAgent]:
|
||||
cid = company_id or settings.paperclip_company_id
|
||||
if not cid:
|
||||
logger.warning("paperclip_company_id not set — cannot list agents")
|
||||
return []
|
||||
data = await self._get(f"/api/companies/{cid}/agents")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [PaperclipAgent(**a) for a in data]
|
||||
|
||||
async def get_agent(self, agent_id: str) -> Optional[PaperclipAgent]:
|
||||
data = await self._get(f"/api/agents/{agent_id}")
|
||||
return PaperclipAgent(**data) if data else None
|
||||
|
||||
async def wake_agent(
|
||||
self,
|
||||
agent_id: str,
|
||||
issue_id: Optional[str] = None,
|
||||
message: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Trigger a heartbeat wake for an agent."""
|
||||
body: Dict[str, Any] = {}
|
||||
if issue_id:
|
||||
body["issueId"] = issue_id
|
||||
if message:
|
||||
body["message"] = message
|
||||
return await self._post(f"/api/agents/{agent_id}/wakeup", json=body)
|
||||
|
||||
async def get_org(self, company_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
cid = company_id or settings.paperclip_company_id
|
||||
if not cid:
|
||||
return None
|
||||
return await self._get(f"/api/companies/{cid}/org")
|
||||
|
||||
# ── issues (tickets) ─────────────────────────────────────────────────
|
||||
|
||||
async def list_issues(
|
||||
self,
|
||||
company_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> List[PaperclipIssue]:
|
||||
cid = company_id or settings.paperclip_company_id
|
||||
if not cid:
|
||||
return []
|
||||
params: Dict[str, str] = {}
|
||||
if status:
|
||||
params["status"] = status
|
||||
data = await self._get(f"/api/companies/{cid}/issues", params=params)
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [PaperclipIssue(**i) for i in data]
|
||||
|
||||
async def get_issue(self, issue_id: str) -> Optional[PaperclipIssue]:
|
||||
data = await self._get(f"/api/issues/{issue_id}")
|
||||
return PaperclipIssue(**data) if data else None
|
||||
|
||||
async def create_issue(
|
||||
self,
|
||||
req: CreateIssueRequest,
|
||||
company_id: Optional[str] = None,
|
||||
) -> Optional[PaperclipIssue]:
|
||||
cid = company_id or settings.paperclip_company_id
|
||||
if not cid:
|
||||
logger.warning("paperclip_company_id not set — cannot create issue")
|
||||
return None
|
||||
data = await self._post(
|
||||
f"/api/companies/{cid}/issues",
|
||||
json=req.model_dump(exclude_none=True),
|
||||
)
|
||||
return PaperclipIssue(**data) if data else None
|
||||
|
||||
async def update_issue(
|
||||
self,
|
||||
issue_id: str,
|
||||
req: UpdateIssueRequest,
|
||||
) -> Optional[PaperclipIssue]:
|
||||
data = await self._patch(
|
||||
f"/api/issues/{issue_id}",
|
||||
json=req.model_dump(exclude_none=True),
|
||||
)
|
||||
return PaperclipIssue(**data) if data else None
|
||||
|
||||
async def delete_issue(self, issue_id: str) -> bool:
|
||||
return await self._delete(f"/api/issues/{issue_id}")
|
||||
|
||||
# ── issue comments ───────────────────────────────────────────────────
|
||||
|
||||
async def list_comments(self, issue_id: str) -> List[PaperclipComment]:
|
||||
data = await self._get(f"/api/issues/{issue_id}/comments")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [PaperclipComment(**c) for c in data]
|
||||
|
||||
async def add_comment(
|
||||
self,
|
||||
issue_id: str,
|
||||
content: str,
|
||||
) -> Optional[PaperclipComment]:
|
||||
data = await self._post(
|
||||
f"/api/issues/{issue_id}/comments",
|
||||
json={"content": content},
|
||||
)
|
||||
return PaperclipComment(**data) if data else None
|
||||
|
||||
# ── issue workflow ───────────────────────────────────────────────────
|
||||
|
||||
async def checkout_issue(self, issue_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Assign an issue to Timmy (checkout)."""
|
||||
body: Dict[str, Any] = {}
|
||||
if settings.paperclip_agent_id:
|
||||
body["agentId"] = settings.paperclip_agent_id
|
||||
return await self._post(f"/api/issues/{issue_id}/checkout", json=body)
|
||||
|
||||
async def release_issue(self, issue_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Release a checked-out issue."""
|
||||
return await self._post(f"/api/issues/{issue_id}/release")
|
||||
|
||||
# ── goals ────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_goals(self, company_id: Optional[str] = None) -> List[PaperclipGoal]:
|
||||
cid = company_id or settings.paperclip_company_id
|
||||
if not cid:
|
||||
return []
|
||||
data = await self._get(f"/api/companies/{cid}/goals")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [PaperclipGoal(**g) for g in data]
|
||||
|
||||
async def create_goal(
|
||||
self,
|
||||
title: str,
|
||||
description: str = "",
|
||||
company_id: Optional[str] = None,
|
||||
) -> Optional[PaperclipGoal]:
|
||||
cid = company_id or settings.paperclip_company_id
|
||||
if not cid:
|
||||
return None
|
||||
data = await self._post(
|
||||
f"/api/companies/{cid}/goals",
|
||||
json={"title": title, "description": description},
|
||||
)
|
||||
return PaperclipGoal(**data) if data else None
|
||||
|
||||
# ── heartbeat runs ───────────────────────────────────────────────────
|
||||
|
||||
async def list_heartbeat_runs(
|
||||
self,
|
||||
company_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
cid = company_id or settings.paperclip_company_id
|
||||
if not cid:
|
||||
return []
|
||||
data = await self._get(f"/api/companies/{cid}/heartbeat-runs")
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def get_run_events(self, run_id: str) -> List[Dict[str, Any]]:
|
||||
data = await self._get(f"/api/heartbeat-runs/{run_id}/events")
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def cancel_run(self, run_id: str) -> Optional[Dict[str, Any]]:
|
||||
return await self._post(f"/api/heartbeat-runs/{run_id}/cancel")
|
||||
|
||||
# ── approvals ────────────────────────────────────────────────────────
|
||||
|
||||
async def list_approvals(self, company_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
cid = company_id or settings.paperclip_company_id
|
||||
if not cid:
|
||||
return []
|
||||
data = await self._get(f"/api/companies/{cid}/approvals")
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def approve(self, approval_id: str, comment: str = "") -> Optional[Dict[str, Any]]:
|
||||
body: Dict[str, Any] = {}
|
||||
if comment:
|
||||
body["comment"] = comment
|
||||
return await self._post(f"/api/approvals/{approval_id}/approve", json=body)
|
||||
|
||||
async def reject(self, approval_id: str, comment: str = "") -> Optional[Dict[str, Any]]:
|
||||
body: Dict[str, Any] = {}
|
||||
if comment:
|
||||
body["comment"] = comment
|
||||
return await self._post(f"/api/approvals/{approval_id}/reject", json=body)
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
paperclip = PaperclipClient()
|
||||
120
src/integrations/paperclip/models.py
Normal file
120
src/integrations/paperclip/models.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Pydantic models for Paperclip AI API objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Inbound: Paperclip → Timmy ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class PaperclipIssue(BaseModel):
|
||||
"""A ticket/issue in Paperclip's task system."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
description: str = ""
|
||||
status: str = "open"
|
||||
priority: Optional[str] = None
|
||||
assignee_id: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
labels: List[str] = Field(default_factory=list)
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class PaperclipComment(BaseModel):
|
||||
"""A comment on a Paperclip issue."""
|
||||
|
||||
id: str
|
||||
issue_id: str
|
||||
content: str
|
||||
author: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class PaperclipAgent(BaseModel):
|
||||
"""An agent in the Paperclip org chart."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
role: str = ""
|
||||
status: str = "active"
|
||||
adapter_type: Optional[str] = None
|
||||
company_id: Optional[str] = None
|
||||
|
||||
|
||||
class PaperclipGoal(BaseModel):
|
||||
"""A company goal in Paperclip."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
description: str = ""
|
||||
status: str = "active"
|
||||
company_id: Optional[str] = None
|
||||
|
||||
|
||||
class HeartbeatRun(BaseModel):
|
||||
"""A heartbeat execution run."""
|
||||
|
||||
id: str
|
||||
agent_id: str
|
||||
status: str
|
||||
issue_id: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
||||
# ── Outbound: Timmy → Paperclip ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class CreateIssueRequest(BaseModel):
|
||||
"""Request to create a new issue in Paperclip."""
|
||||
|
||||
title: str
|
||||
description: str = ""
|
||||
priority: Optional[str] = None
|
||||
assignee_id: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
labels: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateIssueRequest(BaseModel):
|
||||
"""Request to update an existing issue."""
|
||||
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
assignee_id: Optional[str] = None
|
||||
|
||||
|
||||
class AddCommentRequest(BaseModel):
|
||||
"""Request to add a comment to an issue."""
|
||||
|
||||
content: str
|
||||
|
||||
|
||||
class WakeAgentRequest(BaseModel):
|
||||
"""Request to wake an agent via heartbeat."""
|
||||
|
||||
issue_id: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
# ── API route models ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PaperclipStatusResponse(BaseModel):
|
||||
"""Response for GET /api/paperclip/status."""
|
||||
|
||||
enabled: bool
|
||||
connected: bool = False
|
||||
paperclip_url: str = ""
|
||||
company_id: str = ""
|
||||
agent_count: int = 0
|
||||
issue_count: int = 0
|
||||
error: Optional[str] = None
|
||||
214
src/integrations/paperclip/task_runner.py
Normal file
214
src/integrations/paperclip/task_runner.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Paperclip task runner — automated issue processing loop.
|
||||
|
||||
Timmy grabs open issues assigned to him, processes each one, posts a
|
||||
completion comment, marks the issue done, and creates a recursive
|
||||
follow-up task for himself.
|
||||
|
||||
Green-path workflow:
|
||||
1. Poll Paperclip for open issues assigned to Timmy
|
||||
2. Check out the first issue in queue
|
||||
3. Process it (delegate to orchestrator via execute_task)
|
||||
4. Post completion comment with the result
|
||||
5. Mark the issue done
|
||||
6. Create a follow-up task for himself (recursive musing)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Callable, Coroutine, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
from config import settings
|
||||
from integrations.paperclip.bridge import PaperclipBridge, bridge as default_bridge
|
||||
from integrations.paperclip.models import PaperclipIssue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Orchestrator(Protocol):
|
||||
"""Anything with an ``execute_task`` matching Timmy's orchestrator."""
|
||||
|
||||
async def execute_task(
|
||||
self, task_id: str, description: str, context: dict
|
||||
) -> Any: ...
|
||||
|
||||
|
||||
def _wrap_orchestrator(orch: Orchestrator) -> Callable:
|
||||
"""Adapt an orchestrator's execute_task to the process_fn signature."""
|
||||
|
||||
async def _process(task_id: str, description: str, context: Dict) -> str:
|
||||
raw = await orch.execute_task(task_id, description, context)
|
||||
# execute_task may return str or dict — normalise to str
|
||||
if isinstance(raw, dict):
|
||||
return raw.get("result", str(raw))
|
||||
return str(raw)
|
||||
|
||||
return _process
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
"""Autonomous task loop: grab → process → complete → follow-up.
|
||||
|
||||
Wire an *orchestrator* (anything with ``execute_task``) and the runner
|
||||
pushes issues through the real agent pipe. Falls back to a plain
|
||||
``process_fn`` callable or a no-op default.
|
||||
|
||||
The runner operates on a single cycle via ``run_once`` (testable) or
|
||||
continuously via ``start`` with ``paperclip_poll_interval``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: Optional[PaperclipBridge] = None,
|
||||
orchestrator: Optional[Orchestrator] = None,
|
||||
process_fn: Optional[Callable[[str, str, Dict], Coroutine[Any, Any, str]]] = None,
|
||||
):
|
||||
self.bridge = bridge or default_bridge
|
||||
self.orchestrator = orchestrator
|
||||
|
||||
# Priority: explicit process_fn > orchestrator wrapper > default
|
||||
if process_fn:
|
||||
self._process_fn = process_fn
|
||||
elif orchestrator:
|
||||
self._process_fn = _wrap_orchestrator(orchestrator)
|
||||
else:
|
||||
self._process_fn = None
|
||||
|
||||
self._running = False
|
||||
|
||||
# ── single cycle ──────────────────────────────────────────────────
|
||||
|
||||
async def grab_next_task(self) -> Optional[PaperclipIssue]:
|
||||
"""Grab the first open issue assigned to Timmy."""
|
||||
agent_id = settings.paperclip_agent_id
|
||||
if not agent_id:
|
||||
logger.warning("paperclip_agent_id not set — cannot grab tasks")
|
||||
return None
|
||||
|
||||
issues = await self.bridge.client.list_issues(status="open")
|
||||
# Filter to issues assigned to Timmy, take the first one
|
||||
for issue in issues:
|
||||
if issue.assignee_id == agent_id:
|
||||
return issue
|
||||
|
||||
return None
|
||||
|
||||
async def process_task(self, issue: PaperclipIssue) -> str:
|
||||
"""Process an issue: check out, run through the orchestrator, return result."""
|
||||
# Check out the issue so others know we're working on it
|
||||
await self.bridge.client.checkout_issue(issue.id)
|
||||
|
||||
context = {
|
||||
"issue_id": issue.id,
|
||||
"title": issue.title,
|
||||
"priority": issue.priority,
|
||||
"labels": issue.labels,
|
||||
}
|
||||
|
||||
if self._process_fn:
|
||||
result = await self._process_fn(issue.id, issue.description or issue.title, context)
|
||||
else:
|
||||
result = f"Processed task: {issue.title}"
|
||||
|
||||
return result
|
||||
|
||||
async def complete_task(self, issue: PaperclipIssue, result: str) -> bool:
|
||||
"""Post completion comment and mark issue done."""
|
||||
# Post the result as a comment
|
||||
await self.bridge.client.add_comment(
|
||||
issue.id,
|
||||
f"[Timmy] Task completed.\n\n{result}",
|
||||
)
|
||||
|
||||
# Mark the issue as done
|
||||
return await self.bridge.close_issue(issue.id, comment=None)
|
||||
|
||||
async def create_follow_up(self, original: PaperclipIssue, result: str) -> Optional[PaperclipIssue]:
|
||||
"""Create a recursive follow-up task for Timmy.
|
||||
|
||||
Timmy muses about task automation and writes a follow-up issue
|
||||
assigned to himself — the recursive self-improvement loop.
|
||||
"""
|
||||
follow_up_title = f"Follow-up: {original.title}"
|
||||
follow_up_description = (
|
||||
f"Automated follow-up from completed task '{original.title}' "
|
||||
f"(issue {original.id}).\n\n"
|
||||
f"Previous result:\n{result}\n\n"
|
||||
"Review the outcome and determine if further action is needed. "
|
||||
"Muse about task automation improvements and recursive self-improvement."
|
||||
)
|
||||
|
||||
return await self.bridge.create_and_assign(
|
||||
title=follow_up_title,
|
||||
description=follow_up_description,
|
||||
assignee_id=settings.paperclip_agent_id,
|
||||
priority=original.priority,
|
||||
wake=False, # Don't wake immediately — let the next poll pick it up
|
||||
)
|
||||
|
||||
async def run_once(self) -> Optional[Dict[str, Any]]:
|
||||
"""Execute one full cycle of the green-path workflow.
|
||||
|
||||
Returns a summary dict on success, None if no work found.
|
||||
"""
|
||||
# Step 1: Grab next task
|
||||
issue = await self.grab_next_task()
|
||||
if not issue:
|
||||
logger.debug("No tasks in queue for Timmy")
|
||||
return None
|
||||
|
||||
logger.info("Grabbed task %s: %s", issue.id, issue.title)
|
||||
|
||||
# Step 2: Process the task
|
||||
result = await self.process_task(issue)
|
||||
logger.info("Processed task %s", issue.id)
|
||||
|
||||
# Step 3: Complete it
|
||||
completed = await self.complete_task(issue, result)
|
||||
if not completed:
|
||||
logger.warning("Failed to mark task %s as done", issue.id)
|
||||
|
||||
# Step 4: Create follow-up
|
||||
follow_up = await self.create_follow_up(issue, result)
|
||||
follow_up_id = follow_up.id if follow_up else None
|
||||
if follow_up:
|
||||
logger.info("Created follow-up %s for task %s", follow_up.id, issue.id)
|
||||
|
||||
return {
|
||||
"original_issue_id": issue.id,
|
||||
"original_title": issue.title,
|
||||
"result": result,
|
||||
"completed": completed,
|
||||
"follow_up_issue_id": follow_up_id,
|
||||
}
|
||||
|
||||
# ── continuous loop ───────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Run the task loop continuously using paperclip_poll_interval."""
|
||||
interval = settings.paperclip_poll_interval
|
||||
if interval <= 0:
|
||||
logger.info("Task runner disabled (poll_interval=%d)", interval)
|
||||
return
|
||||
|
||||
self._running = True
|
||||
logger.info("Task runner started (poll every %ds)", interval)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await self.run_once()
|
||||
except Exception as exc:
|
||||
logger.error("Task runner cycle failed: %s", exc)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal the loop to stop."""
|
||||
self._running = False
|
||||
logger.info("Task runner stopping")
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
task_runner = TaskRunner()
|
||||
148
tests/dashboard/test_paperclip_routes.py
Normal file
148
tests/dashboard/test_paperclip_routes.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Tests for the Paperclip API routes."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
from integrations.paperclip.models import PaperclipIssue, PaperclipAgent, PaperclipGoal
|
||||
|
||||
|
||||
# ── GET /api/paperclip/status ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_status_disabled(client):
|
||||
"""When paperclip_enabled is False, status returns disabled."""
|
||||
response = client.get("/api/paperclip/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["enabled"] is False
|
||||
|
||||
|
||||
def test_status_enabled(client):
|
||||
mock_status = MagicMock()
|
||||
mock_status.model_dump.return_value = {
|
||||
"enabled": True,
|
||||
"connected": True,
|
||||
"paperclip_url": "http://vps:3100",
|
||||
"company_id": "comp-1",
|
||||
"agent_count": 3,
|
||||
"issue_count": 5,
|
||||
"error": None,
|
||||
}
|
||||
mock_bridge = MagicMock()
|
||||
mock_bridge.get_status = AsyncMock(return_value=mock_status)
|
||||
with patch("dashboard.routes.paperclip.settings") as mock_settings:
|
||||
mock_settings.paperclip_enabled = True
|
||||
with patch.dict("sys.modules", {}):
|
||||
with patch("integrations.paperclip.bridge.bridge", mock_bridge):
|
||||
response = client.get("/api/paperclip/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["connected"] is True
|
||||
|
||||
|
||||
# ── GET /api/paperclip/issues ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_issues_disabled(client):
|
||||
response = client.get("/api/paperclip/issues")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is False
|
||||
|
||||
|
||||
# ── POST /api/paperclip/issues ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_issue_disabled(client):
|
||||
response = client.post(
|
||||
"/api/paperclip/issues",
|
||||
json={"title": "Test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is False
|
||||
|
||||
|
||||
def test_create_issue_missing_title(client):
|
||||
with patch("dashboard.routes.paperclip.settings") as mock_settings:
|
||||
mock_settings.paperclip_enabled = True
|
||||
response = client.post(
|
||||
"/api/paperclip/issues",
|
||||
json={"description": "No title"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "title" in response.json()["error"]
|
||||
|
||||
|
||||
# ── POST /api/paperclip/issues/{id}/delegate ─────────────────────────────────
|
||||
|
||||
|
||||
def test_delegate_issue_missing_agent_id(client):
|
||||
with patch("dashboard.routes.paperclip.settings") as mock_settings:
|
||||
mock_settings.paperclip_enabled = True
|
||||
response = client.post(
|
||||
"/api/paperclip/issues/i1/delegate",
|
||||
json={"message": "Do this"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "agent_id" in response.json()["error"]
|
||||
|
||||
|
||||
# ── POST /api/paperclip/issues/{id}/comment ──────────────────────────────────
|
||||
|
||||
|
||||
def test_add_comment_missing_content(client):
|
||||
with patch("dashboard.routes.paperclip.settings") as mock_settings:
|
||||
mock_settings.paperclip_enabled = True
|
||||
response = client.post(
|
||||
"/api/paperclip/issues/i1/comment",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "content" in response.json()["error"]
|
||||
|
||||
|
||||
# ── GET /api/paperclip/agents ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_agents_disabled(client):
|
||||
response = client.get("/api/paperclip/agents")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is False
|
||||
|
||||
|
||||
# ── GET /api/paperclip/goals ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_goals_disabled(client):
|
||||
response = client.get("/api/paperclip/goals")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is False
|
||||
|
||||
|
||||
# ── POST /api/paperclip/goals ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_goal_missing_title(client):
|
||||
with patch("dashboard.routes.paperclip.settings") as mock_settings:
|
||||
mock_settings.paperclip_enabled = True
|
||||
response = client.post(
|
||||
"/api/paperclip/goals",
|
||||
json={"description": "No title"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "title" in response.json()["error"]
|
||||
|
||||
|
||||
# ── GET /api/paperclip/approvals ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_approvals_disabled(client):
|
||||
response = client.get("/api/paperclip/approvals")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is False
|
||||
|
||||
|
||||
# ── GET /api/paperclip/runs ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_runs_disabled(client):
|
||||
response = client.get("/api/paperclip/runs")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is False
|
||||
205
tests/integrations/test_paperclip_bridge.py
Normal file
205
tests/integrations/test_paperclip_bridge.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Tests for the Paperclip bridge (CEO orchestration logic)."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.paperclip.bridge import PaperclipBridge
|
||||
from integrations.paperclip.client import PaperclipClient
|
||||
from integrations.paperclip.models import PaperclipAgent, PaperclipGoal, PaperclipIssue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
client = MagicMock(spec=PaperclipClient)
|
||||
# Make all methods async
|
||||
client.healthy = AsyncMock(return_value=True)
|
||||
client.list_agents = AsyncMock(return_value=[])
|
||||
client.list_issues = AsyncMock(return_value=[])
|
||||
client.list_goals = AsyncMock(return_value=[])
|
||||
client.list_approvals = AsyncMock(return_value=[])
|
||||
client.list_heartbeat_runs = AsyncMock(return_value=[])
|
||||
client.get_issue = AsyncMock(return_value=None)
|
||||
client.get_org = AsyncMock(return_value=None)
|
||||
client.create_issue = AsyncMock(return_value=None)
|
||||
client.update_issue = AsyncMock(return_value=None)
|
||||
client.add_comment = AsyncMock(return_value=None)
|
||||
client.wake_agent = AsyncMock(return_value=None)
|
||||
client.create_goal = AsyncMock(return_value=None)
|
||||
client.approve = AsyncMock(return_value=None)
|
||||
client.reject = AsyncMock(return_value=None)
|
||||
client.cancel_run = AsyncMock(return_value=None)
|
||||
client.list_comments = AsyncMock(return_value=[])
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge(mock_client):
|
||||
return PaperclipBridge(client=mock_client)
|
||||
|
||||
|
||||
# ── status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_status_when_disabled(bridge):
|
||||
with patch("integrations.paperclip.bridge.settings") as mock_settings:
|
||||
mock_settings.paperclip_enabled = False
|
||||
mock_settings.paperclip_url = "http://localhost:3100"
|
||||
status = await bridge.get_status()
|
||||
assert status.enabled is False
|
||||
|
||||
|
||||
async def test_status_when_connected(bridge, mock_client):
|
||||
mock_client.healthy.return_value = True
|
||||
mock_client.list_agents.return_value = [
|
||||
PaperclipAgent(id="a1", name="Codex"),
|
||||
]
|
||||
mock_client.list_issues.return_value = [
|
||||
PaperclipIssue(id="i1", title="Bug"),
|
||||
PaperclipIssue(id="i2", title="Feature"),
|
||||
]
|
||||
|
||||
with patch("integrations.paperclip.bridge.settings") as mock_settings:
|
||||
mock_settings.paperclip_enabled = True
|
||||
mock_settings.paperclip_url = "http://vps:3100"
|
||||
mock_settings.paperclip_company_id = "comp-1"
|
||||
status = await bridge.get_status()
|
||||
|
||||
assert status.enabled is True
|
||||
assert status.connected is True
|
||||
assert status.agent_count == 1
|
||||
assert status.issue_count == 2
|
||||
|
||||
|
||||
async def test_status_when_disconnected(bridge, mock_client):
|
||||
mock_client.healthy.return_value = False
|
||||
|
||||
with patch("integrations.paperclip.bridge.settings") as mock_settings:
|
||||
mock_settings.paperclip_enabled = True
|
||||
mock_settings.paperclip_url = "http://vps:3100"
|
||||
mock_settings.paperclip_company_id = "comp-1"
|
||||
status = await bridge.get_status()
|
||||
|
||||
assert status.enabled is True
|
||||
assert status.connected is False
|
||||
assert "Cannot reach" in status.error
|
||||
|
||||
|
||||
# ── create and assign ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_create_and_assign_with_wake(bridge, mock_client):
|
||||
issue = PaperclipIssue(id="i1", title="Deploy v2")
|
||||
mock_client.create_issue.return_value = issue
|
||||
mock_client.wake_agent.return_value = {"status": "queued"}
|
||||
|
||||
result = await bridge.create_and_assign(
|
||||
title="Deploy v2",
|
||||
assignee_id="agent-codex",
|
||||
wake=True,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.id == "i1"
|
||||
mock_client.wake_agent.assert_awaited_once_with("agent-codex", issue_id="i1")
|
||||
|
||||
|
||||
async def test_create_and_assign_no_wake(bridge, mock_client):
|
||||
issue = PaperclipIssue(id="i2", title="Research task")
|
||||
mock_client.create_issue.return_value = issue
|
||||
|
||||
result = await bridge.create_and_assign(
|
||||
title="Research task",
|
||||
assignee_id="agent-research",
|
||||
wake=False,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
mock_client.wake_agent.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_create_and_assign_failure(bridge, mock_client):
|
||||
mock_client.create_issue.return_value = None
|
||||
|
||||
result = await bridge.create_and_assign(title="Will fail")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── delegate ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_delegate_issue(bridge, mock_client):
|
||||
mock_client.update_issue.return_value = PaperclipIssue(id="i1", title="Task")
|
||||
mock_client.wake_agent.return_value = {"status": "queued"}
|
||||
|
||||
ok = await bridge.delegate_issue("i1", "agent-codex", message="Handle this")
|
||||
assert ok is True
|
||||
mock_client.add_comment.assert_awaited_once()
|
||||
mock_client.wake_agent.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_delegate_issue_update_fails(bridge, mock_client):
|
||||
mock_client.update_issue.return_value = None
|
||||
|
||||
ok = await bridge.delegate_issue("i1", "agent-codex")
|
||||
assert ok is False
|
||||
|
||||
|
||||
# ── close issue ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_close_issue(bridge, mock_client):
|
||||
mock_client.update_issue.return_value = PaperclipIssue(id="i1", title="Done")
|
||||
|
||||
ok = await bridge.close_issue("i1", comment="Shipped!")
|
||||
assert ok is True
|
||||
mock_client.add_comment.assert_awaited_once()
|
||||
|
||||
|
||||
# ── goals ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_set_goal(bridge, mock_client):
|
||||
mock_client.create_goal.return_value = PaperclipGoal(id="g1", title="Ship MVP")
|
||||
|
||||
goal = await bridge.set_goal("Ship MVP")
|
||||
assert goal is not None
|
||||
assert goal.title == "Ship MVP"
|
||||
|
||||
|
||||
# ── approvals ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_approve(bridge, mock_client):
|
||||
mock_client.approve.return_value = {"status": "approved"}
|
||||
ok = await bridge.approve("ap1")
|
||||
assert ok is True
|
||||
|
||||
|
||||
async def test_reject(bridge, mock_client):
|
||||
mock_client.reject.return_value = {"status": "rejected"}
|
||||
ok = await bridge.reject("ap1", comment="Needs work")
|
||||
assert ok is True
|
||||
|
||||
|
||||
async def test_approve_failure(bridge, mock_client):
|
||||
mock_client.approve.return_value = None
|
||||
ok = await bridge.approve("ap1")
|
||||
assert ok is False
|
||||
|
||||
|
||||
# ── runs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_active_runs(bridge, mock_client):
|
||||
mock_client.list_heartbeat_runs.return_value = [
|
||||
{"id": "r1", "status": "running"},
|
||||
]
|
||||
runs = await bridge.active_runs()
|
||||
assert len(runs) == 1
|
||||
|
||||
|
||||
async def test_cancel_run(bridge, mock_client):
|
||||
mock_client.cancel_run.return_value = {"status": "cancelled"}
|
||||
ok = await bridge.cancel_run("r1")
|
||||
assert ok is True
|
||||
180
tests/integrations/test_paperclip_client.py
Normal file
180
tests/integrations/test_paperclip_client.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Tests for the Paperclip API client."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.paperclip.client import PaperclipClient
|
||||
from integrations.paperclip.models import CreateIssueRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return PaperclipClient(base_url="http://fake:3100", api_key="test-key")
|
||||
|
||||
|
||||
# ── health ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_healthy_returns_true_on_success(client):
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value={"status": "ok"}):
|
||||
assert await client.healthy() is True
|
||||
|
||||
|
||||
async def test_healthy_returns_false_on_failure(client):
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=None):
|
||||
assert await client.healthy() is False
|
||||
|
||||
|
||||
# ── agents ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_list_agents_returns_list(client):
|
||||
raw = [{"id": "a1", "name": "Codex", "role": "engineer", "status": "active"}]
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
with patch("integrations.paperclip.client.settings") as mock_settings:
|
||||
mock_settings.paperclip_company_id = "comp-1"
|
||||
agents = await client.list_agents(company_id="comp-1")
|
||||
assert len(agents) == 1
|
||||
assert agents[0].name == "Codex"
|
||||
|
||||
|
||||
async def test_list_agents_graceful_on_none(client):
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=None):
|
||||
agents = await client.list_agents(company_id="comp-1")
|
||||
assert agents == []
|
||||
|
||||
|
||||
# ── issues ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_list_issues(client):
|
||||
raw = [{"id": "i1", "title": "Fix bug"}]
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
issues = await client.list_issues(company_id="comp-1")
|
||||
assert len(issues) == 1
|
||||
assert issues[0].title == "Fix bug"
|
||||
|
||||
|
||||
async def test_get_issue(client):
|
||||
raw = {"id": "i1", "title": "Fix bug", "description": "It's broken"}
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
issue = await client.get_issue("i1")
|
||||
assert issue is not None
|
||||
assert issue.id == "i1"
|
||||
|
||||
|
||||
async def test_get_issue_not_found(client):
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=None):
|
||||
issue = await client.get_issue("nonexistent")
|
||||
assert issue is None
|
||||
|
||||
|
||||
async def test_create_issue(client):
|
||||
raw = {"id": "i2", "title": "New feature"}
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=raw):
|
||||
req = CreateIssueRequest(title="New feature")
|
||||
issue = await client.create_issue(req, company_id="comp-1")
|
||||
assert issue is not None
|
||||
assert issue.id == "i2"
|
||||
|
||||
|
||||
async def test_create_issue_no_company_id(client):
|
||||
with patch("integrations.paperclip.client.settings") as mock_settings:
|
||||
mock_settings.paperclip_company_id = ""
|
||||
issue = await client.create_issue(
|
||||
CreateIssueRequest(title="Test"),
|
||||
)
|
||||
assert issue is None
|
||||
|
||||
|
||||
async def test_delete_issue(client):
|
||||
with patch.object(client, "_delete", new_callable=AsyncMock, return_value=True):
|
||||
result = await client.delete_issue("i1")
|
||||
assert result is True
|
||||
|
||||
|
||||
# ── comments ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_add_comment(client):
|
||||
raw = {"id": "c1", "issue_id": "i1", "content": "Done"}
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=raw):
|
||||
comment = await client.add_comment("i1", "Done")
|
||||
assert comment is not None
|
||||
assert comment.content == "Done"
|
||||
|
||||
|
||||
async def test_list_comments(client):
|
||||
raw = [{"id": "c1", "issue_id": "i1", "content": "LGTM"}]
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
comments = await client.list_comments("i1")
|
||||
assert len(comments) == 1
|
||||
|
||||
|
||||
# ── goals ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_list_goals(client):
|
||||
raw = [{"id": "g1", "title": "Ship MVP"}]
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
goals = await client.list_goals(company_id="comp-1")
|
||||
assert len(goals) == 1
|
||||
assert goals[0].title == "Ship MVP"
|
||||
|
||||
|
||||
async def test_create_goal(client):
|
||||
raw = {"id": "g2", "title": "Scale to 1000 users"}
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=raw):
|
||||
goal = await client.create_goal("Scale to 1000 users", company_id="comp-1")
|
||||
assert goal is not None
|
||||
|
||||
|
||||
# ── wake agent ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_wake_agent(client):
|
||||
raw = {"status": "queued"}
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=raw):
|
||||
result = await client.wake_agent("a1", issue_id="i1")
|
||||
assert result == {"status": "queued"}
|
||||
|
||||
|
||||
async def test_wake_agent_failure(client):
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=None):
|
||||
result = await client.wake_agent("a1")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── approvals ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_approve(client):
|
||||
raw = {"status": "approved"}
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=raw):
|
||||
result = await client.approve("ap1", comment="LGTM")
|
||||
assert result is not None
|
||||
|
||||
|
||||
async def test_reject(client):
|
||||
raw = {"status": "rejected"}
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=raw):
|
||||
result = await client.reject("ap1", comment="Needs work")
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ── heartbeat runs ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_list_heartbeat_runs(client):
|
||||
raw = [{"id": "r1", "agent_id": "a1", "status": "running"}]
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
runs = await client.list_heartbeat_runs(company_id="comp-1")
|
||||
assert len(runs) == 1
|
||||
|
||||
|
||||
async def test_cancel_run(client):
|
||||
raw = {"status": "cancelled"}
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=raw):
|
||||
result = await client.cancel_run("r1")
|
||||
assert result is not None
|
||||
848
tests/integrations/test_paperclip_task_runner.py
Normal file
848
tests/integrations/test_paperclip_task_runner.py
Normal file
@@ -0,0 +1,848 @@
|
||||
"""Integration tests for the Paperclip task runner — full green-path workflow.
|
||||
|
||||
Tests the complete autonomous cycle with a StubOrchestrator that exercises
|
||||
the real pipe (TaskRunner → orchestrator.execute_task → bridge → client)
|
||||
while stubbing only the LLM intelligence layer.
|
||||
|
||||
Green path:
|
||||
1. Timmy grabs first task in queue
|
||||
2. Orchestrator.execute_task processes it (stub returns input-aware response)
|
||||
3. Timmy posts completion comment and marks issue done
|
||||
4. Timmy creates a recursive follow-up task for himself
|
||||
|
||||
The stub is deliberately input-aware — it echoes back task metadata so
|
||||
assertions can prove data actually flowed through the pipe, not just that
|
||||
methods were called.
|
||||
|
||||
Live-LLM tests (``@pytest.mark.ollama``) are at the bottom; they hit a real
|
||||
tiny model via Ollama and are skipped when Ollama is not running.
|
||||
Run them with: ``tox -e ollama`` or ``pytest -m ollama``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.paperclip.bridge import PaperclipBridge
|
||||
from integrations.paperclip.client import PaperclipClient
|
||||
from integrations.paperclip.models import (
|
||||
PaperclipIssue,
|
||||
)
|
||||
from integrations.paperclip.task_runner import TaskRunner
|
||||
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
TIMMY_AGENT_ID = "agent-timmy"
|
||||
COMPANY_ID = "comp-1"
|
||||
|
||||
|
||||
# ── StubOrchestrator: exercises the pipe, stubs the intelligence ──────────────
|
||||
|
||||
|
||||
class StubOrchestrator:
|
||||
"""Deterministic orchestrator that proves data flows through the pipe.
|
||||
|
||||
Returns responses that reference input metadata — so tests can assert
|
||||
the pipe actually connected (task_id, title, priority all appear in output).
|
||||
Tracks every call for post-hoc inspection.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def execute_task(
|
||||
self, task_id: str, description: str, context: dict
|
||||
) -> dict:
|
||||
call_record = {
|
||||
"task_id": task_id,
|
||||
"description": description,
|
||||
"context": dict(context),
|
||||
}
|
||||
self.calls.append(call_record)
|
||||
|
||||
title = context.get("title", description[:50])
|
||||
priority = context.get("priority", "normal")
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"agent": "orchestrator",
|
||||
"result": (
|
||||
f"[Orchestrator] Processed '{title}'. "
|
||||
f"Task {task_id} handled with priority {priority}. "
|
||||
"Self-reflection: my task automation loop is functioning. "
|
||||
"I should create a follow-up to review this pattern."
|
||||
),
|
||||
"status": "completed",
|
||||
}
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_orchestrator():
|
||||
return StubOrchestrator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Fully stubbed PaperclipClient with async methods."""
|
||||
client = MagicMock(spec=PaperclipClient)
|
||||
client.healthy = AsyncMock(return_value=True)
|
||||
client.list_issues = AsyncMock(return_value=[])
|
||||
client.get_issue = AsyncMock(return_value=None)
|
||||
client.create_issue = AsyncMock(return_value=None)
|
||||
client.update_issue = AsyncMock(return_value=None)
|
||||
client.delete_issue = AsyncMock(return_value=True)
|
||||
client.add_comment = AsyncMock(return_value=None)
|
||||
client.list_comments = AsyncMock(return_value=[])
|
||||
client.checkout_issue = AsyncMock(return_value={"ok": True})
|
||||
client.release_issue = AsyncMock(return_value={"ok": True})
|
||||
client.wake_agent = AsyncMock(return_value=None)
|
||||
client.list_agents = AsyncMock(return_value=[])
|
||||
client.list_goals = AsyncMock(return_value=[])
|
||||
client.create_goal = AsyncMock(return_value=None)
|
||||
client.list_approvals = AsyncMock(return_value=[])
|
||||
client.list_heartbeat_runs = AsyncMock(return_value=[])
|
||||
client.cancel_run = AsyncMock(return_value=None)
|
||||
client.approve = AsyncMock(return_value=None)
|
||||
client.reject = AsyncMock(return_value=None)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge(mock_client):
|
||||
return PaperclipBridge(client=mock_client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings_patch():
|
||||
"""Patch settings for all task runner tests."""
|
||||
with patch("integrations.paperclip.task_runner.settings") as ts, \
|
||||
patch("integrations.paperclip.bridge.settings") as bs:
|
||||
for s in (ts, bs):
|
||||
s.paperclip_enabled = True
|
||||
s.paperclip_agent_id = TIMMY_AGENT_ID
|
||||
s.paperclip_company_id = COMPANY_ID
|
||||
s.paperclip_url = "http://fake:3100"
|
||||
s.paperclip_poll_interval = 0
|
||||
yield ts
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_issue(
|
||||
id: str = "issue-1",
|
||||
title: str = "Muse about task automation",
|
||||
description: str = "Reflect on how you handle tasks and write a recursive self-improvement task.",
|
||||
status: str = "open",
|
||||
assignee_id: str = TIMMY_AGENT_ID,
|
||||
priority: str = "normal",
|
||||
labels: list[str] | None = None,
|
||||
) -> PaperclipIssue:
|
||||
return PaperclipIssue(
|
||||
id=id,
|
||||
title=title,
|
||||
description=description,
|
||||
status=status,
|
||||
assignee_id=assignee_id,
|
||||
priority=priority,
|
||||
labels=labels or [],
|
||||
)
|
||||
|
||||
|
||||
def _make_done(id: str = "issue-1", title: str = "Done") -> PaperclipIssue:
|
||||
return PaperclipIssue(id=id, title=title, status="done")
|
||||
|
||||
|
||||
def _make_follow_up(id: str = "issue-2") -> PaperclipIssue:
|
||||
return PaperclipIssue(
|
||||
id=id,
|
||||
title="Follow-up: Muse about task automation",
|
||||
description="Automated follow-up from completed task",
|
||||
status="open",
|
||||
assignee_id=TIMMY_AGENT_ID,
|
||||
priority="normal",
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# PIPE WIRING: verify orchestrator is actually connected
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestOrchestratorWiring:
|
||||
"""Verify the orchestrator parameter actually connects to the pipe."""
|
||||
|
||||
async def test_orchestrator_execute_task_is_called(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
"""When orchestrator is wired, process_task calls execute_task."""
|
||||
issue = _make_issue()
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
result = await runner.process_task(issue)
|
||||
|
||||
assert len(stub_orchestrator.calls) == 1
|
||||
call = stub_orchestrator.calls[0]
|
||||
assert call["task_id"] == "issue-1"
|
||||
assert call["context"]["title"] == "Muse about task automation"
|
||||
|
||||
async def test_orchestrator_receives_full_context(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
"""Context dict passed to execute_task includes all issue metadata."""
|
||||
issue = _make_issue(
|
||||
id="ctx-test",
|
||||
title="Context verification",
|
||||
priority="high",
|
||||
labels=["automation", "meta"],
|
||||
)
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
await runner.process_task(issue)
|
||||
|
||||
ctx = stub_orchestrator.calls[0]["context"]
|
||||
assert ctx["issue_id"] == "ctx-test"
|
||||
assert ctx["title"] == "Context verification"
|
||||
assert ctx["priority"] == "high"
|
||||
assert ctx["labels"] == ["automation", "meta"]
|
||||
|
||||
async def test_orchestrator_dict_result_unwrapped(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
"""When execute_task returns a dict, the 'result' key is extracted."""
|
||||
issue = _make_issue()
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
result = await runner.process_task(issue)
|
||||
|
||||
# StubOrchestrator returns dict with "result" key
|
||||
assert "[Orchestrator]" in result
|
||||
assert "issue-1" in result
|
||||
|
||||
async def test_orchestrator_string_result_passthrough(
|
||||
self, mock_client, bridge, settings_patch,
|
||||
):
|
||||
"""When execute_task returns a plain string, it passes through."""
|
||||
|
||||
class StringOrchestrator:
|
||||
async def execute_task(self, task_id, description, context):
|
||||
return f"Plain string result for {task_id}"
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=StringOrchestrator())
|
||||
result = await runner.process_task(_make_issue())
|
||||
|
||||
assert result == "Plain string result for issue-1"
|
||||
|
||||
async def test_process_fn_overrides_orchestrator(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
"""Explicit process_fn takes priority over orchestrator."""
|
||||
|
||||
async def override(task_id, desc, ctx):
|
||||
return "override wins"
|
||||
|
||||
runner = TaskRunner(
|
||||
bridge=bridge, orchestrator=stub_orchestrator, process_fn=override,
|
||||
)
|
||||
result = await runner.process_task(_make_issue())
|
||||
|
||||
assert result == "override wins"
|
||||
assert len(stub_orchestrator.calls) == 0 # orchestrator NOT called
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# STEP 1: Timmy grabs the first task in queue
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGrabNextTask:
|
||||
"""Verify Timmy picks the first open issue assigned to him."""
|
||||
|
||||
async def test_grabs_first_assigned_issue(self, mock_client, bridge, settings_patch):
|
||||
issue = _make_issue()
|
||||
mock_client.list_issues.return_value = [issue]
|
||||
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
grabbed = await runner.grab_next_task()
|
||||
|
||||
assert grabbed is not None
|
||||
assert grabbed.id == "issue-1"
|
||||
assert grabbed.assignee_id == TIMMY_AGENT_ID
|
||||
mock_client.list_issues.assert_awaited_once_with(status="open")
|
||||
|
||||
async def test_skips_issues_not_assigned_to_timmy(self, mock_client, bridge, settings_patch):
|
||||
other = _make_issue(id="other-1", assignee_id="agent-codex")
|
||||
mine = _make_issue(id="timmy-1")
|
||||
mock_client.list_issues.return_value = [other, mine]
|
||||
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
grabbed = await runner.grab_next_task()
|
||||
|
||||
assert grabbed.id == "timmy-1"
|
||||
|
||||
async def test_returns_none_when_queue_empty(self, mock_client, bridge, settings_patch):
|
||||
mock_client.list_issues.return_value = []
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
assert await runner.grab_next_task() is None
|
||||
|
||||
async def test_returns_none_when_no_agent_id(self, mock_client, bridge, settings_patch):
|
||||
settings_patch.paperclip_agent_id = ""
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
assert await runner.grab_next_task() is None
|
||||
mock_client.list_issues.assert_not_awaited()
|
||||
|
||||
async def test_grabs_first_of_multiple(self, mock_client, bridge, settings_patch):
|
||||
issues = [_make_issue(id=f"t-{i}", title=f"Task {i}") for i in range(3)]
|
||||
mock_client.list_issues.return_value = issues
|
||||
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
assert (await runner.grab_next_task()).id == "t-0"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# STEP 2: Timmy processes the task through the orchestrator
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestProcessTask:
|
||||
"""Verify checkout + orchestrator invocation + result flow."""
|
||||
|
||||
async def test_checkout_before_orchestrator(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
"""Issue must be checked out before orchestrator runs."""
|
||||
issue = _make_issue()
|
||||
checkout_happened = {"before_execute": False}
|
||||
|
||||
original_execute = stub_orchestrator.execute_task
|
||||
|
||||
async def tracking_execute(task_id, desc, ctx):
|
||||
checkout_happened["before_execute"] = (
|
||||
mock_client.checkout_issue.await_count > 0
|
||||
)
|
||||
return await original_execute(task_id, desc, ctx)
|
||||
|
||||
stub_orchestrator.execute_task = tracking_execute
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
await runner.process_task(issue)
|
||||
|
||||
assert checkout_happened["before_execute"], "checkout must happen before execute_task"
|
||||
|
||||
async def test_orchestrator_output_flows_to_result(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
"""The string returned by process_task comes from the orchestrator."""
|
||||
issue = _make_issue(id="flow-1", title="Flow verification", priority="high")
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
result = await runner.process_task(issue)
|
||||
|
||||
# Verify orchestrator's output arrived — it references the input
|
||||
assert "Flow verification" in result
|
||||
assert "flow-1" in result
|
||||
assert "high" in result
|
||||
|
||||
async def test_default_fallback_without_orchestrator(
|
||||
self, mock_client, bridge, settings_patch,
|
||||
):
|
||||
"""Without orchestrator or process_fn, a default message is returned."""
|
||||
issue = _make_issue(title="Fallback test")
|
||||
runner = TaskRunner(bridge=bridge) # no orchestrator
|
||||
result = await runner.process_task(issue)
|
||||
assert "Fallback test" in result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# STEP 3: Timmy completes the task — comment + close
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCompleteTask:
|
||||
"""Verify orchestrator output flows into the completion comment."""
|
||||
|
||||
async def test_orchestrator_output_in_comment(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
"""The comment posted to Paperclip contains the orchestrator's output."""
|
||||
issue = _make_issue(id="cmt-1", title="Comment pipe test")
|
||||
mock_client.update_issue.return_value = _make_done("cmt-1")
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
# Process to get orchestrator output
|
||||
result = await runner.process_task(issue)
|
||||
# Complete to post it as comment
|
||||
await runner.complete_task(issue, result)
|
||||
|
||||
comment_content = mock_client.add_comment.call_args[0][1]
|
||||
assert "[Timmy]" in comment_content
|
||||
assert "[Orchestrator]" in comment_content
|
||||
assert "Comment pipe test" in comment_content
|
||||
|
||||
async def test_marks_issue_done(
|
||||
self, mock_client, bridge, settings_patch,
|
||||
):
|
||||
issue = _make_issue()
|
||||
mock_client.update_issue.return_value = _make_done()
|
||||
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
ok = await runner.complete_task(issue, "any result")
|
||||
|
||||
assert ok is True
|
||||
update_req = mock_client.update_issue.call_args[0][1]
|
||||
assert update_req.status == "done"
|
||||
|
||||
async def test_returns_false_on_close_failure(
|
||||
self, mock_client, bridge, settings_patch,
|
||||
):
|
||||
mock_client.update_issue.return_value = None
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
assert await runner.complete_task(_make_issue(), "result") is False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# STEP 4: Follow-up creation with orchestrator output embedded
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCreateFollowUp:
|
||||
"""Verify orchestrator output flows into the follow-up description."""
|
||||
|
||||
async def test_follow_up_contains_orchestrator_output(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
"""The follow-up description includes the orchestrator's result text."""
|
||||
issue = _make_issue(id="fu-1", title="Follow-up pipe test")
|
||||
mock_client.create_issue.return_value = _make_follow_up()
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
result = await runner.process_task(issue)
|
||||
await runner.create_follow_up(issue, result)
|
||||
|
||||
create_req = mock_client.create_issue.call_args[0][0]
|
||||
# Orchestrator output should be embedded in description
|
||||
assert "[Orchestrator]" in create_req.description
|
||||
assert "fu-1" in create_req.description
|
||||
|
||||
async def test_follow_up_assigned_to_self(
|
||||
self, mock_client, bridge, settings_patch,
|
||||
):
|
||||
mock_client.create_issue.return_value = _make_follow_up()
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
await runner.create_follow_up(_make_issue(), "result")
|
||||
|
||||
req = mock_client.create_issue.call_args[0][0]
|
||||
assert req.assignee_id == TIMMY_AGENT_ID
|
||||
|
||||
async def test_follow_up_preserves_priority(
|
||||
self, mock_client, bridge, settings_patch,
|
||||
):
|
||||
mock_client.create_issue.return_value = _make_follow_up()
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
await runner.create_follow_up(_make_issue(priority="high"), "result")
|
||||
|
||||
req = mock_client.create_issue.call_args[0][0]
|
||||
assert req.priority == "high"
|
||||
|
||||
async def test_follow_up_not_woken(self, mock_client, bridge, settings_patch):
|
||||
mock_client.create_issue.return_value = _make_follow_up()
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
await runner.create_follow_up(_make_issue(), "result")
|
||||
mock_client.wake_agent.assert_not_awaited()
|
||||
|
||||
async def test_returns_none_on_failure(self, mock_client, bridge, settings_patch):
|
||||
mock_client.create_issue.return_value = None
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
assert await runner.create_follow_up(_make_issue(), "r") is None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# FULL GREEN PATH: orchestrator wired end-to-end
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGreenPathWithOrchestrator:
|
||||
"""Full pipe: TaskRunner → StubOrchestrator → bridge → mock_client.
|
||||
|
||||
Proves orchestrator output propagates to every downstream artefact:
|
||||
the comment, the follow-up description, and the summary dict.
|
||||
"""
|
||||
|
||||
async def test_full_cycle_orchestrator_output_everywhere(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
"""Orchestrator result appears in comment, follow-up, and summary."""
|
||||
original = _make_issue(
|
||||
id="green-1",
|
||||
title="Muse about task automation and write a recursive task",
|
||||
description="Reflect on your task processing. Create a follow-up.",
|
||||
priority="high",
|
||||
)
|
||||
mock_client.list_issues.return_value = [original]
|
||||
mock_client.update_issue.return_value = _make_done("green-1")
|
||||
mock_client.create_issue.return_value = _make_follow_up("green-fu")
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
summary = await runner.run_once()
|
||||
|
||||
# ── Orchestrator was called with correct data
|
||||
assert len(stub_orchestrator.calls) == 1
|
||||
call = stub_orchestrator.calls[0]
|
||||
assert call["task_id"] == "green-1"
|
||||
assert call["context"]["priority"] == "high"
|
||||
assert "Reflect on your task processing" in call["description"]
|
||||
|
||||
# ── Summary contains orchestrator output
|
||||
assert summary is not None
|
||||
assert summary["original_issue_id"] == "green-1"
|
||||
assert summary["completed"] is True
|
||||
assert summary["follow_up_issue_id"] == "green-fu"
|
||||
assert "[Orchestrator]" in summary["result"]
|
||||
assert "green-1" in summary["result"]
|
||||
|
||||
# ── Comment posted contains orchestrator output
|
||||
comment_content = mock_client.add_comment.call_args[0][1]
|
||||
assert "[Timmy]" in comment_content
|
||||
assert "[Orchestrator]" in comment_content
|
||||
assert "high" in comment_content # priority flowed through
|
||||
|
||||
# ── Follow-up description contains orchestrator output
|
||||
follow_up_req = mock_client.create_issue.call_args[0][0]
|
||||
assert "[Orchestrator]" in follow_up_req.description
|
||||
assert "green-1" in follow_up_req.description
|
||||
assert follow_up_req.priority == "high"
|
||||
assert follow_up_req.assignee_id == TIMMY_AGENT_ID
|
||||
|
||||
# ── Correct ordering of API calls
|
||||
mock_client.list_issues.assert_awaited_once()
|
||||
mock_client.checkout_issue.assert_awaited_once_with("green-1")
|
||||
mock_client.add_comment.assert_awaited_once()
|
||||
mock_client.update_issue.assert_awaited_once()
|
||||
assert mock_client.create_issue.await_count == 1
|
||||
|
||||
async def test_no_tasks_returns_none(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
mock_client.list_issues.return_value = []
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
assert await runner.run_once() is None
|
||||
assert len(stub_orchestrator.calls) == 0
|
||||
|
||||
async def test_close_failure_still_creates_follow_up(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
mock_client.list_issues.return_value = [_make_issue()]
|
||||
mock_client.update_issue.return_value = None # close fails
|
||||
mock_client.create_issue.return_value = _make_follow_up()
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
summary = await runner.run_once()
|
||||
|
||||
assert summary["completed"] is False
|
||||
assert summary["follow_up_issue_id"] == "issue-2"
|
||||
assert len(stub_orchestrator.calls) == 1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# EXTERNAL INJECTION: task from Paperclip API → orchestrator processes it
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestExternalTaskInjection:
|
||||
"""External system creates a task → Timmy's orchestrator processes it."""
|
||||
|
||||
async def test_external_task_flows_through_orchestrator(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
external = _make_issue(
|
||||
id="ext-1",
|
||||
title="Review quarterly metrics",
|
||||
description="Analyze Q1 metrics and prepare summary.",
|
||||
)
|
||||
mock_client.list_issues.return_value = [external]
|
||||
mock_client.update_issue.return_value = _make_done("ext-1")
|
||||
mock_client.create_issue.return_value = _make_follow_up("ext-fu")
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
summary = await runner.run_once()
|
||||
|
||||
# Orchestrator received the external task
|
||||
assert stub_orchestrator.calls[0]["task_id"] == "ext-1"
|
||||
assert "Analyze Q1 metrics" in stub_orchestrator.calls[0]["description"]
|
||||
|
||||
# Its output flowed to Paperclip
|
||||
assert "[Orchestrator]" in summary["result"]
|
||||
assert "Review quarterly metrics" in summary["result"]
|
||||
|
||||
async def test_skips_tasks_for_other_agents(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
other = _make_issue(id="other-1", assignee_id="agent-codex")
|
||||
mine = _make_issue(id="mine-1", title="My task")
|
||||
mock_client.list_issues.return_value = [other, mine]
|
||||
mock_client.update_issue.return_value = _make_done("mine-1")
|
||||
mock_client.create_issue.return_value = _make_follow_up()
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
summary = await runner.run_once()
|
||||
|
||||
assert summary["original_issue_id"] == "mine-1"
|
||||
mock_client.checkout_issue.assert_awaited_once_with("mine-1")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# RECURSIVE CHAIN: follow-up → grabbed → orchestrator → follow-up → ...
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestRecursiveChain:
|
||||
"""Multi-cycle chains where each follow-up becomes the next task."""
|
||||
|
||||
async def test_two_cycle_chain(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
task_a = _make_issue(id="A", title="Initial musing")
|
||||
fu_b = PaperclipIssue(
|
||||
id="B", title="Follow-up: Initial musing",
|
||||
description="Continue", status="open",
|
||||
assignee_id=TIMMY_AGENT_ID, priority="normal",
|
||||
)
|
||||
fu_c = PaperclipIssue(
|
||||
id="C", title="Follow-up: Follow-up",
|
||||
status="open", assignee_id=TIMMY_AGENT_ID,
|
||||
)
|
||||
|
||||
# Cycle 1
|
||||
mock_client.list_issues.return_value = [task_a]
|
||||
mock_client.update_issue.return_value = _make_done("A")
|
||||
mock_client.create_issue.return_value = fu_b
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
s1 = await runner.run_once()
|
||||
assert s1["original_issue_id"] == "A"
|
||||
assert s1["follow_up_issue_id"] == "B"
|
||||
|
||||
# Cycle 2: follow-up B is now the task
|
||||
mock_client.list_issues.return_value = [fu_b]
|
||||
mock_client.update_issue.return_value = _make_done("B")
|
||||
mock_client.create_issue.return_value = fu_c
|
||||
|
||||
s2 = await runner.run_once()
|
||||
assert s2["original_issue_id"] == "B"
|
||||
assert s2["follow_up_issue_id"] == "C"
|
||||
|
||||
# Orchestrator was called twice — once per cycle
|
||||
assert len(stub_orchestrator.calls) == 2
|
||||
assert stub_orchestrator.calls[0]["task_id"] == "A"
|
||||
assert stub_orchestrator.calls[1]["task_id"] == "B"
|
||||
|
||||
async def test_three_cycle_chain_all_through_orchestrator(
|
||||
self, mock_client, bridge, stub_orchestrator, settings_patch,
|
||||
):
|
||||
"""Three cycles — every task goes through the orchestrator pipe."""
|
||||
tasks = [_make_issue(id=f"c-{i}", title=f"Chain {i}") for i in range(3)]
|
||||
follow_ups = [
|
||||
PaperclipIssue(
|
||||
id=f"c-{i + 1}", title=f"Follow-up: Chain {i}",
|
||||
status="open", assignee_id=TIMMY_AGENT_ID,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator)
|
||||
ids = []
|
||||
|
||||
for i in range(3):
|
||||
mock_client.list_issues.return_value = [tasks[i]]
|
||||
mock_client.update_issue.return_value = _make_done(tasks[i].id)
|
||||
mock_client.create_issue.return_value = follow_ups[i]
|
||||
|
||||
s = await runner.run_once()
|
||||
ids.append(s["original_issue_id"])
|
||||
|
||||
assert ids == ["c-0", "c-1", "c-2"]
|
||||
assert len(stub_orchestrator.calls) == 3
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LIFECYCLE: start/stop
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
|
||||
async def test_stop_halts_loop(self, mock_client, bridge, settings_patch):
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
runner._running = True
|
||||
runner.stop()
|
||||
assert runner._running is False
|
||||
|
||||
async def test_start_disabled_when_interval_zero(
|
||||
self, mock_client, bridge, settings_patch,
|
||||
):
|
||||
settings_patch.paperclip_poll_interval = 0
|
||||
runner = TaskRunner(bridge=bridge)
|
||||
await runner.start()
|
||||
mock_client.list_issues.assert_not_awaited()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LIVE LLM (manual e2e): runs only when Ollama is available
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _ollama_reachable() -> tuple[bool, list[str]]:
|
||||
"""Return (reachable, model_names)."""
|
||||
try:
|
||||
import httpx
|
||||
resp = httpx.get("http://localhost:11434/api/tags", timeout=3)
|
||||
resp.raise_for_status()
|
||||
names = [m["name"] for m in resp.json().get("models", [])]
|
||||
return True, names
|
||||
except Exception:
|
||||
return False, []
|
||||
|
||||
|
||||
def _pick_tiny_model(available: list[str]) -> str | None:
|
||||
"""Pick the smallest model available for e2e tests."""
|
||||
candidates = ["tinyllama", "phi", "qwen2:0.5b", "llama3.2:1b", "gemma:2b"]
|
||||
for candidate in candidates:
|
||||
for name in available:
|
||||
if candidate in name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
class LiveOllamaOrchestrator:
|
||||
"""Thin orchestrator that calls Ollama directly — no Agno dependency."""
|
||||
|
||||
def __init__(self, model_name: str) -> None:
|
||||
self.model_name = model_name
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def execute_task(
|
||||
self, task_id: str, description: str, context: dict
|
||||
) -> str:
|
||||
import httpx as hx
|
||||
|
||||
self.calls.append({"task_id": task_id, "description": description})
|
||||
|
||||
async with hx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
"http://localhost:11434/api/generate",
|
||||
json={
|
||||
"model": self.model_name,
|
||||
"prompt": (
|
||||
f"You are Timmy, a task automation agent. "
|
||||
f"Task: {description}\n"
|
||||
f"Respond in 1-2 sentences about what you did."
|
||||
),
|
||||
"stream": False,
|
||||
"options": {"num_predict": 64},
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["response"]
|
||||
|
||||
|
||||
@pytest.mark.ollama
|
||||
class TestLiveOllamaGreenPath:
|
||||
"""Green-path with a real tiny LLM via Ollama.
|
||||
|
||||
Run with: ``tox -e ollama`` or ``pytest -m ollama``
|
||||
Requires: Ollama running with a small model.
|
||||
"""
|
||||
|
||||
async def test_live_full_cycle(self, mock_client, bridge, settings_patch):
|
||||
"""Wire a real tiny LLM through the full pipe and verify output."""
|
||||
reachable, models = _ollama_reachable()
|
||||
if not reachable:
|
||||
pytest.skip("Ollama not reachable at localhost:11434")
|
||||
|
||||
chosen = _pick_tiny_model(models)
|
||||
if not chosen:
|
||||
pytest.skip(f"No tiny model found (have: {models[:5]})")
|
||||
|
||||
issue = _make_issue(
|
||||
id="live-1",
|
||||
title="Reflect on task automation",
|
||||
description="Muse about how you process tasks and suggest improvements.",
|
||||
)
|
||||
mock_client.list_issues.return_value = [issue]
|
||||
mock_client.update_issue.return_value = _make_done("live-1")
|
||||
mock_client.create_issue.return_value = _make_follow_up("live-fu")
|
||||
|
||||
live_orch = LiveOllamaOrchestrator(chosen)
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=live_orch)
|
||||
summary = await runner.run_once()
|
||||
|
||||
# The LLM produced *something* non-empty
|
||||
assert summary is not None
|
||||
assert len(summary["result"]) > 0
|
||||
assert summary["completed"] is True
|
||||
assert summary["follow_up_issue_id"] == "live-fu"
|
||||
|
||||
# Orchestrator was actually called
|
||||
assert len(live_orch.calls) == 1
|
||||
assert live_orch.calls[0]["task_id"] == "live-1"
|
||||
|
||||
# LLM output flowed into the Paperclip comment
|
||||
comment = mock_client.add_comment.call_args[0][1]
|
||||
assert "[Timmy]" in comment
|
||||
assert len(comment) > len("[Timmy] Task completed.\n\n")
|
||||
|
||||
# LLM output flowed into the follow-up description
|
||||
fu_req = mock_client.create_issue.call_args[0][0]
|
||||
assert len(fu_req.description) > 0
|
||||
assert fu_req.assignee_id == TIMMY_AGENT_ID
|
||||
|
||||
async def test_live_recursive_chain(self, mock_client, bridge, settings_patch):
|
||||
"""Two-cycle chain with a real LLM — each cycle produces real output."""
|
||||
reachable, models = _ollama_reachable()
|
||||
if not reachable:
|
||||
pytest.skip("Ollama not reachable")
|
||||
|
||||
chosen = _pick_tiny_model(models)
|
||||
if not chosen:
|
||||
pytest.skip("No tiny model found")
|
||||
|
||||
task_a = _make_issue(id="live-A", title="Initial reflection")
|
||||
fu_b = PaperclipIssue(
|
||||
id="live-B", title="Follow-up: Initial reflection",
|
||||
description="Continue reflecting", status="open",
|
||||
assignee_id=TIMMY_AGENT_ID, priority="normal",
|
||||
)
|
||||
fu_c = PaperclipIssue(
|
||||
id="live-C", title="Follow-up: Follow-up",
|
||||
status="open", assignee_id=TIMMY_AGENT_ID,
|
||||
)
|
||||
|
||||
live_orch = LiveOllamaOrchestrator(chosen)
|
||||
runner = TaskRunner(bridge=bridge, orchestrator=live_orch)
|
||||
|
||||
# Cycle 1
|
||||
mock_client.list_issues.return_value = [task_a]
|
||||
mock_client.update_issue.return_value = _make_done("live-A")
|
||||
mock_client.create_issue.return_value = fu_b
|
||||
|
||||
s1 = await runner.run_once()
|
||||
assert s1 is not None
|
||||
assert len(s1["result"]) > 0
|
||||
|
||||
# Cycle 2
|
||||
mock_client.list_issues.return_value = [fu_b]
|
||||
mock_client.update_issue.return_value = _make_done("live-B")
|
||||
mock_client.create_issue.return_value = fu_c
|
||||
|
||||
s2 = await runner.run_once()
|
||||
assert s2 is not None
|
||||
assert len(s2["result"]) > 0
|
||||
|
||||
# Both cycles went through the LLM
|
||||
assert len(live_orch.calls) == 2
|
||||
43
tests/test_paperclip_config.py
Normal file
43
tests/test_paperclip_config.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Paperclip AI config settings."""
|
||||
|
||||
|
||||
def test_paperclip_url_default():
|
||||
from config import settings
|
||||
|
||||
assert hasattr(settings, "paperclip_url")
|
||||
assert settings.paperclip_url == "http://localhost:3100"
|
||||
|
||||
|
||||
def test_paperclip_enabled_default_false():
|
||||
from config import settings
|
||||
|
||||
assert hasattr(settings, "paperclip_enabled")
|
||||
assert settings.paperclip_enabled is False
|
||||
|
||||
|
||||
def test_paperclip_timeout_default():
|
||||
from config import settings
|
||||
|
||||
assert hasattr(settings, "paperclip_timeout")
|
||||
assert settings.paperclip_timeout == 30
|
||||
|
||||
|
||||
def test_paperclip_agent_id_default_empty():
|
||||
from config import settings
|
||||
|
||||
assert hasattr(settings, "paperclip_agent_id")
|
||||
assert settings.paperclip_agent_id == ""
|
||||
|
||||
|
||||
def test_paperclip_company_id_default_empty():
|
||||
from config import settings
|
||||
|
||||
assert hasattr(settings, "paperclip_company_id")
|
||||
assert settings.paperclip_company_id == ""
|
||||
|
||||
|
||||
def test_paperclip_poll_interval_default_zero():
|
||||
from config import settings
|
||||
|
||||
assert hasattr(settings, "paperclip_poll_interval")
|
||||
assert settings.paperclip_poll_interval == 0
|
||||
19
tox.ini
Normal file
19
tox.ini
Normal file
@@ -0,0 +1,19 @@
|
||||
[tox]
|
||||
envlist = unit, integration
|
||||
skipsdist = true
|
||||
|
||||
[testenv]
|
||||
allowlist_externals = poetry
|
||||
commands_pre = poetry install --with dev --quiet
|
||||
|
||||
[testenv:unit]
|
||||
description = Fast unit + integration tests (no Ollama, no external services)
|
||||
commands = poetry run pytest tests/ -q --tb=short -m "not ollama and not docker and not selenium and not external_api"
|
||||
|
||||
[testenv:integration]
|
||||
description = Same as unit — alias for CI pipelines
|
||||
commands = poetry run pytest tests/ -q --tb=short -m "not ollama and not docker and not selenium and not external_api"
|
||||
|
||||
[testenv:ollama]
|
||||
description = Live LLM tests via Ollama (requires Ollama running with a tiny model)
|
||||
commands = poetry run pytest tests/ -q --tb=short -m ollama --timeout=120
|
||||
Reference in New Issue
Block a user